diff --git a/src/calibration/strategies_inspector.py b/src/calibration/strategies_inspector.py index 02dc4ce..72f152a 100644 --- a/src/calibration/strategies_inspector.py +++ b/src/calibration/strategies_inspector.py @@ -21,27 +21,11 @@ from src.risk.sizing.percent_risk import PercentRiskSizer # -------------------------------------------------- # Strategy registry (con metadata de parámetros) # -------------------------------------------------- +from src.strategies.registry import STRATEGY_REGISTRY 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 # -------------------------------------------------- @@ -49,15 +33,24 @@ STRATEGY_REGISTRY = { def list_available_strategies() -> List[Dict[str, Any]]: """ Devuelve metadata completa para UI. + Usa parameters_schema() como fuente de verdad. """ - out = [] - for sid, entry in STRATEGY_REGISTRY.items(): + out: List[Dict[str, Any]] = [] + + for strategy_id, strategy_class in STRATEGY_REGISTRY.items(): + + if not hasattr(strategy_class, "parameters_schema"): + continue + + schema = strategy_class.parameters_schema() + out.append({ - "strategy_id": sid, - "name": entry["class"].__name__, - "params": entry["params"], - "tags": [], # puedes rellenar más adelante + "strategy_id": strategy_id, + "name": strategy_class.__name__, + "params": list(schema.keys()), + "parameters_schema": schema, # 🔥 ahora enviamos schema completo + "tags": [], }) return out @@ -67,7 +60,7 @@ 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)) + return TrailingStop(trailing_fraction=float(stop_schema.stop_fraction)) if stop_schema.type == "atr": return ATRStop( atr_period=int(stop_schema.atr_period), @@ -94,27 +87,6 @@ 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 # -------------------------------------------------- @@ -162,14 +134,21 @@ def inspect_strategies_config( step_td = pd.Timedelta(days=int(payload.wf.step_days or payload.wf.test_days)) overall_status = "ok" + + log.info(f"🔥 Strategies received: {len(payload.strategies)}") + results: List[Dict[str, Any]] = [] series: Dict[str, Any] = {"strategies": {}} if include_series else {} + log.info(f"🔥 Strategies received: {len(payload.strategies)}") + for sel in payload.strategies: sid = sel.strategy_id entry = STRATEGY_REGISTRY.get(sid) + log.info(f"🧠 Step3 | Processing strategy: {sid}") + if entry is None: results.append({ "strategy_id": sid, @@ -183,10 +162,13 @@ def inspect_strategies_config( "windows": [], }) overall_status = "fail" + log.error(f"❌ Strategy not found in registry: {sid}") continue - strategy_class = entry["class"] - valid_params = set(entry["params"]) + strategy_class = STRATEGY_REGISTRY[sid] + + schema = strategy_class.parameters_schema() + valid_params = set(schema.keys()) range_params = set(sel.parameters.keys()) @@ -207,19 +189,15 @@ def inspect_strategies_config( }) overall_status = "fail" continue - + # -------------------------------------------------- - # Convert ranges -> param_grid real + # Build fixed_params (VALIDATION MODE) # -------------------------------------------------- - param_grid = {} + fixed_params = {} + + for pname, pvalue in sel.parameters.items(): + fixed_params[pname] = pvalue - 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)): @@ -248,7 +226,8 @@ def inspect_strategies_config( try: wf = WalkForwardValidator( strategy_class=strategy_class, - param_grid=param_grid, + param_grid=None, + fixed_params=fixed_params, data=df, train_window=train_td, test_window=test_td, @@ -256,49 +235,60 @@ def inspect_strategies_config( 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"] + oos_returns = [] + oos_dd = [] + warnings_list = [] + n_windows = 0 + if win_df is None or win_df.empty: - status = "fail" - msg = "WF produced no valid windows" - overall_status = "fail" - windows_out = [] - oos_returns = [] + status = "warning" + msg = "No closed trades in OOS" + warnings_list.append("Walk-forward produced no closed trades.") else: - trades = win_df["trades"].astype(int).tolist() - too_few = sum(t < int(payload.optimization.min_trades_test) for t in trades) + oos_returns = win_df["return_pct"].tolist() + oos_dd = win_df["max_dd_pct"].tolist() + n_windows = len(win_df) - 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" + trades = win_df["trades"].astype(int).tolist() + too_few = sum(t < int(payload.wf.min_trades_test) for t in trades) - 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"], - }) + if too_few > 0: + warnings_list.append( + f"{too_few} test windows have fewer than {payload.wf.min_trades_test} trades" + ) + + windows_out = [] + + if warnings_list: + status = "warning" + msg = "Validation completed with warnings" + if overall_status == "ok": + overall_status = "warning" + else: + status = "ok" + msg = "WF OK" + + 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() @@ -311,6 +301,7 @@ def inspect_strategies_config( "strategy_id": sid, "status": status, "message": msg, + "warnings": warnings_list if status == "warning" else [], "n_windows": int(len(windows_out)), "oos_final_equity": oos_final, "oos_total_return_pct": float(oos_total_return), @@ -323,6 +314,7 @@ def inspect_strategies_config( series["strategies"][sid] = { "window_returns_pct": oos_returns, "window_equity": eq_curve, + "window_trades": win_df["trades"].tolist(), } except Exception as e: diff --git a/src/core/walk_forward.py b/src/core/walk_forward.py index b762460..1204cb6 100644 --- a/src/core/walk_forward.py +++ b/src/core/walk_forward.py @@ -1,6 +1,6 @@ -# src/backtest/walk_forward.py +# src/core/walk_forward.py import pandas as pd -from typing import List, Dict, Optional +from typing import List, Dict, Optional, Callable, Any from src.core.optimizer import ParameterOptimizer from src.core.engine import Engine from src.risk.sizing.base import PositionSizer @@ -19,7 +19,7 @@ class WalkForwardValidator: def __init__( self, strategy_class, - param_grid: dict, + param_grid: Optional[dict], data: pd.DataFrame, train_window: pd.Timedelta, test_window: pd.Timedelta, @@ -34,9 +34,12 @@ class WalkForwardValidator: stop_loss: Optional[StopLoss] = None, max_combinations: Optional[int] = None, progress_callback: Optional[callable] = None, + fixed_params: Optional[dict] = None, ): self.strategy_class = strategy_class self.param_grid = param_grid + self.fixed_params = fixed_params + self.data = data.sort_index() self.train_window = train_window @@ -61,6 +64,13 @@ class WalkForwardValidator: if not self.data.index.is_monotonic_increasing: raise ValueError("data.index debe estar ordenado cronológicamente") + + # ✅ Validación de modo (NUEVO, mínimo y claro) + if self.param_grid is not None and self.fixed_params is not None: + raise ValueError("WalkForwardValidator: usa param_grid (optimization) o fixed_params (validation), no ambos.") + + if self.param_grid is None and self.fixed_params is None: + raise ValueError("WalkForwardValidator: debes pasar param_grid o fixed_params.") # ------------------------------------------------------------------ # 🔹 GENERACIÓN DE VENTANAS TEMPORALES @@ -186,26 +196,33 @@ class WalkForwardValidator: continue # 1️⃣ Optimización TRAIN - optimizer = ParameterOptimizer( - strategy_class=self.strategy_class, - data=train_data, - initial_capital=self.initial_capital, - commission=self.commission, - slippage=self.slippage, - position_size=self.position_size, - position_sizer=self.position_sizer, - stop_loss=self.stop_loss, - max_combinations=self.max_combinations, - ) + best_train_metric = None - opt_df = optimizer.optimize(self.param_grid) + if self.fixed_params is not None: + # ✅ VALIDATION MODE: sin optimización + best_params = self.fixed_params + else: + # ✅ OPTIMIZATION MODE + optimizer = ParameterOptimizer( + strategy_class=self.strategy_class, + data=train_data, + initial_capital=self.initial_capital, + commission=self.commission, + slippage=self.slippage, + position_size=self.position_size, + position_sizer=self.position_sizer, + stop_loss=self.stop_loss, + max_combinations=self.max_combinations, + ) - if opt_df.empty: - log.warning(f"WF #{wid} sin resultados de optimización") - continue + opt_df = optimizer.optimize(self.param_grid) - best_params = optimizer.get_best_params(metric=self.optimizer_metric) - best_train_metric = opt_df[self.optimizer_metric].max() + if opt_df.empty: + log.warning(f"WF #{wid} sin resultados de optimización") + continue + + best_params = optimizer.get_best_params(metric=self.optimizer_metric) + best_train_metric = opt_df[self.optimizer_metric].max() # 2️⃣ Backtest TEST (OOS) strategy = self.strategy_class(**best_params) @@ -261,6 +278,7 @@ class WalkForwardValidator: "n_windows": len(rows), "data_start": self.data.index.min(), "data_end": self.data.index.max(), + "mode": "validation" if self.fixed_params is not None else "optimization" }, "windows": pd.DataFrame(rows), "raw_results": raw_results, diff --git a/src/strategies/moving_average.py b/src/strategies/moving_average.py index 3256e6e..df5d1d7 100644 --- a/src/strategies/moving_average.py +++ b/src/strategies/moving_average.py @@ -10,7 +10,12 @@ class MovingAverageCrossover(Strategy): """ Estrategia de cruce de medias móviles - Señales: + Señales:@classmethod + def default_parameters(cls) -> dict: + return { + "fast_period": 10, + "slow_period": 30, + } - BUY: Cruce alcista de medias + (ADX >= threshold si está activado) - SELL: Cruce bajista de medias - HOLD: En cualquier otro caso @@ -28,6 +33,8 @@ class MovingAverageCrossover(Strategy): Sin ADX todavía → primero evaluamos la señal “pura” """ + strategy_id = "moving_average" + def __init__( self, fast_period: int = 20, @@ -55,7 +62,38 @@ class MovingAverageCrossover(Strategy): if self.ma_type not in ['sma', 'ema']: raise ValueError("ma_type debe ser 'sma' o 'ema'") - # ------------------------------------------------------------------ + @classmethod + def parameters_schema(cls) -> dict: + return { + "fast_period": { + "type": "int", + "min": 1, + "max": 500, + "default": 20, + }, + "slow_period": { + "type": "int", + "min": 1, + "max": 500, + "default": 50, + }, + "ma_type": { + "type": "enum", + "choices": ["sma", "ema"], + "default": "ema", + }, + "use_adx": { + "type": "bool", + "default": False, + }, + "adx_threshold": { + "type": "float", + "min": 1, + "max": 100, + "default": 20.0, + }, + } + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: """ diff --git a/src/strategies/registry.py b/src/strategies/registry.py new file mode 100644 index 0000000..baa0c90 --- /dev/null +++ b/src/strategies/registry.py @@ -0,0 +1,16 @@ +# src/strategies/registry.py +from .moving_average import MovingAverageCrossover +from .rsi_strategy import RSIStrategy +from .buy_and_hold import BuyAndHold + + +ALL_STRATEGIES = [ + MovingAverageCrossover, + RSIStrategy, +] + + +STRATEGY_REGISTRY = { + cls.strategy_id: cls + for cls in ALL_STRATEGIES +} diff --git a/src/strategies/rsi_strategy.py b/src/strategies/rsi_strategy.py index 5656667..79b312f 100644 --- a/src/strategies/rsi_strategy.py +++ b/src/strategies/rsi_strategy.py @@ -20,6 +20,8 @@ class RSIStrategy(Strategy): overbought_threshold: Umbral de sobrecompra (default: 70) """ + strategy_id = "rsi" + def __init__(self, rsi_period: int = 14, oversold_threshold: float = 30, overbought_threshold: float = 70): params = { @@ -33,7 +35,31 @@ class RSIStrategy(Strategy): self.rsi_period = rsi_period self.oversold = oversold_threshold self.overbought = overbought_threshold - + + @classmethod + def parameters_schema(cls) -> dict: + return { + "rsi_period": { + "type": "int", + "min": 1, + "max": 200, + "default": 14, + }, + "oversold": { + "type": "float", + "min": 0, + "max": 100, + "default": 30, + }, + "overbought": { + "type": "float", + "min": 0, + "max": 100, + "default": 70, + }, + } + + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: """ Calcula el RSI diff --git a/src/web/api/v2/routers/calibration_strategies.py b/src/web/api/v2/routers/calibration_strategies.py index d0460c6..a27b925 100644 --- a/src/web/api/v2/routers/calibration_strategies.py +++ b/src/web/api/v2/routers/calibration_strategies.py @@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, HTMLResponse from src.data.storage import StorageManager +from src.strategies.registry import STRATEGY_REGISTRY from src.calibration.strategies_inspector import ( inspect_strategies_config, list_available_strategies, @@ -37,22 +38,40 @@ def get_storage() -> StorageManager: @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"] - ] + enriched = [] + + for s in strategies: + + strategy_id = s["strategy_id"] + strategy_class = STRATEGY_REGISTRY[strategy_id] + + schema = strategy_class.parameters_schema() + + parameters_meta = [] + + for name, meta in schema.items(): + + parameters_meta.append({ + "name": name, + "type": meta.get("type"), + "default_value": meta.get("default"), + "choices": meta.get("choices"), + "min": meta.get("min"), + "max": meta.get("max"), + }) + + enriched.append({ + "strategy_id": strategy_id, + "name": s["name"], + "params": list(schema.keys()), + "parameters_meta": parameters_meta, + }) + + return {"strategies": enriched} - return {"strategies": strategies} @router.post("/inspect", response_model=CalibrationStrategiesInspectResponse) def inspect_strategies( @@ -147,9 +166,9 @@ def report_strategies( "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, + "Min trades per window (test)": payload.wf.min_trades_test, }, + results=result, ) diff --git a/src/web/api/v2/schemas/calibration_strategies.py b/src/web/api/v2/schemas/calibration_strategies.py index d1c9ad3..c1edb96 100644 --- a/src/web/api/v2/schemas/calibration_strategies.py +++ b/src/web/api/v2/schemas/calibration_strategies.py @@ -1,33 +1,24 @@ # src/web/api/v2/schemas/calibration_strategies.py -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional, Union from pydantic import BaseModel, Field from .calibration_risk import StopConfigSchema, RiskConfigSchema, GlobalRiskRulesSchema +ParameterValue = Union[int, float, bool, str] + + 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) + step_days: Optional[int] = Field(None, gt=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] + parameters: Dict[str, ParameterValue] class CalibrationStrategiesInspectRequest(BaseModel): @@ -42,7 +33,6 @@ class CalibrationStrategiesInspectRequest(BaseModel): strategies: List[StrategySelectionSchema] wf: WalkForwardConfigSchema - optimization: OptimizationConfigSchema commission: float = Field(0.001, ge=0) slippage: float = Field(0.0005, ge=0) 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 46c1cfb..2373258 100644 --- a/src/web/ui/v2/static/js/pages/calibration_strategies.js +++ b/src/web/ui/v2/static/js/pages/calibration_strategies.js @@ -4,6 +4,8 @@ console.log("[calibration_strategies] script loaded ✅", new Date().toISOString let STRATEGY_CATALOG = []; let strategySlots = []; +let selectedStrategyId = null; +let lastValidationResult = null; const MAX_STRATEGIES = 10; // ================================================= @@ -49,8 +51,6 @@ function loadContextFromLocalStorage() { 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); @@ -61,8 +61,415 @@ function loadContextFromLocalStorage() { 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); + if (account_equity) setVal("account_equity", account_equity); + + // WF defaults (if stored) + const wf_train_days = localStorage.getItem("calibration.wf.train_days"); + const wf_test_days = localStorage.getItem("calibration.wf.test_days"); + const wf_step_days = localStorage.getItem("calibration.wf.step_days"); + const wf_min_trades_test = localStorage.getItem("calibration.wf.min_trades_test"); + + if (wf_train_days) setVal("wf_train_days", wf_train_days); + if (wf_test_days) setVal("wf_test_days", wf_test_days); + if (wf_step_days) setVal("wf_step_days", wf_step_days); + if (wf_min_trades_test) setVal("wf_min_trades_test", wf_min_trades_test); + + // Optional fees + const commission = localStorage.getItem("calibration.commission"); + const slippage = localStorage.getItem("calibration.slippage"); + if (commission) setVal("commission", commission); + if (slippage) setVal("slippage", slippage); } +function setVal(id, v) { + const el = document.getElementById(id); + if (el) el.value = v; +} + +function str(id) { + const el = document.getElementById(id); + return el ? String(el.value || "").trim() : ""; +} + +function num(id) { + const el = document.getElementById(id); + if (!el) return null; + const v = parseFloat(el.value); + return Number.isFinite(v) ? v : null; +} + +function sleep(ms) { + return new Promise(res => setTimeout(res, ms)); +} + +// ================================================= +// FETCH CATALOG +// ================================================= + +async function fetchCatalog() { + const symbol = str("symbol"); + const timeframe = str("timeframe"); + + const res = await fetch(`/api/v2/calibration/strategies/catalog?symbol=${encodeURIComponent(symbol)}&timeframe=${encodeURIComponent(timeframe)}`); + if (!res.ok) throw new Error(`catalog failed: ${res.status}`); + const data = await res.json(); + + STRATEGY_CATALOG = data.strategies || []; + console.log("[catalog] strategies:", STRATEGY_CATALOG.map(s => s.strategy_id)); +} + +// ================================================= +// UI RENDERING +// ================================================= + +function initSlots() { + strategySlots = [ + { strategy_id: null, parameters: {} } + ]; +} + +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: slotData.parameters || {} + }); + + renderStrategySlot(index); + }); + + // always one empty slot at end (if room) + if (strategySlots.length < MAX_STRATEGIES) { + strategySlots.push({ strategy_id: null, parameters: {} }); + renderStrategySlot(strategySlots.length - 1); + } + + updateCombinationCounter(); +} + +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); + + const select = document.getElementById(`strategy_select_${index}`); + const removeBtn = document.getElementById(`remove_strategy_${index}`); + + // set initial value + select.value = strategySlots[index].strategy_id || ""; + + select.addEventListener("change", (e) => { + onStrategySelected(index, e.target.value); + }); + + removeBtn.addEventListener("click", () => { + removeStrategySlot(index); + }); + + // if already selected, render params + if (strategySlots[index].strategy_id) { + renderParametersOnly(index, strategySlots[index].strategy_id); + } +} + +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 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 || !strategyMeta.parameters_meta) return; + + strategyMeta.parameters_meta.forEach(meta => { + + const col = document.createElement("div"); + col.className = "col-md-4"; + + let inputHtml = ""; + const paramName = meta.name; + + // INT / FLOAT + if (meta.type === "int" || meta.type === "float") { + + inputHtml = ` + + `; + } + + // ENUM + else if (meta.type === "enum") { + + const options = (meta.choices || []).map(choice => ` + + `).join(""); + + inputHtml = ` + + `; + } + + // BOOL + else if (meta.type === "bool") { + + inputHtml = ` + + `; + } + + col.innerHTML = ` + + ${inputHtml} + `; + + paramsContainer.appendChild(col); + }); + + validateParameterInputs(); +} + +function validateParameterInputs() { + + let valid = true; + + // Limpiar estados previos + 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 + ); + + if (!strategyMeta || !strategyMeta.parameters_meta) return; + + strategyMeta.parameters_meta.forEach(meta => { + + const el = document.getElementById(`${meta.name}_value_${index}`); + if (!el) return; + + let raw = el.value; + let value = raw; + + // 🔹 INT + if (meta.type === "int") { + value = parseInt(raw); + if (isNaN(value)) valid = false; + } + + // 🔹 FLOAT + else if (meta.type === "float") { + value = parseFloat(raw); + if (isNaN(value)) valid = false; + } + + // 🔹 BOOL + else if (meta.type === "bool") { + if (raw !== "true" && raw !== "false") valid = false; + } + + // 🔹 ENUM + else if (meta.type === "enum") { + if (!meta.choices || !meta.choices.includes(raw)) { + valid = false; + } + } + + // 🔹 Rango (solo números) + if ((meta.type === "int" || meta.type === "float") && !isNaN(value)) { + + if (meta.min !== null && meta.min !== undefined && value < meta.min) { + valid = false; + } + + if (meta.max !== null && meta.max !== undefined && value > meta.max) { + valid = false; + } + } + + if (!valid) { + el.classList.add("is-invalid"); + } + + }); + + }); + + updateCombinationCounter(); + + return valid; +} + +function updateCombinationCounter() { + + let hasAnyStrategy = false; + + strategySlots.forEach((slot, index) => { + + if (!slot.strategy_id) return; + + hasAnyStrategy = true; + + const perStrategyEl = document.getElementById(`strategy_combo_${index}`); + if (perStrategyEl) { + perStrategyEl.textContent = "1"; + } + }); + + const globalTotal = hasAnyStrategy ? 1 : 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(); +} + +// ================================================= +// PAYLOAD +// ================================================= + function buildPayload() { const symbol = str("symbol"); const timeframe = str("timeframe"); @@ -95,6 +502,7 @@ function buildPayload() { 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 wf_min_trades_test = num("wf_min_trades_test") const strategies = collectSelectedStrategies(); @@ -115,14 +523,8 @@ function buildPayload() { train_days: wf_train_days, test_days: wf_test_days, step_days: wf_step_days, + min_trades_test: wf_min_trades_test, }, - 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, }; @@ -132,6 +534,8 @@ function collectSelectedStrategies() { const strategies = []; + console.log("strategySlots:", strategySlots); + strategySlots.forEach((slot, index) => { if (!slot.strategy_id) return; @@ -140,35 +544,40 @@ function collectSelectedStrategies() { s => s.strategy_id === slot.strategy_id ); + if (!strategyMeta) return; + const parameters = {}; - strategyMeta.params.forEach(paramName => { + strategyMeta.parameters_meta.forEach(meta => { - const min = parseFloat( - document.getElementById(`${paramName}_min_${index}`)?.value - ); + const el = document.getElementById(`${meta.name}_value_${index}`); + if (!el) return; - const max = parseFloat( - document.getElementById(`${paramName}_max_${index}`)?.value - ); + let value = el.value; - const step = parseFloat( - document.getElementById(`${paramName}_step_${index}`)?.value - ); + if (meta.type === "int") { + value = parseInt(value); + } + else if (meta.type === "float") { + value = parseFloat(value); + } + else if (meta.type === "bool") { + value = value === "true"; + } - parameters[paramName] = { - min: min, - max: max, - step: step - }; + parameters[meta.name] = value; }); + strategies.push({ strategy_id: slot.strategy_id, parameters: parameters }); + }); + console.log("Collected strategies:", strategies); + return strategies; } @@ -289,309 +698,6 @@ function addStrategySlot() { 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 // ================================================= @@ -770,6 +876,8 @@ function renderPlotsForSelected(data) { function renderValidateResponse(data) { + lastValidationResult = data; + // ------------------------------- // 1️⃣ Badge + message // ------------------------------- @@ -795,25 +903,25 @@ function renderValidateResponse(data) { // 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 } }); + const strategies = data.series.strategies; + const keys = Object.keys(strategies); - Plotly.newPlot("plot_returns", [{ - y: s.window_returns_pct, - type: "bar", - name: "Window returns %" - }], { margin: { t: 20 } }); + if (!selectedStrategyId && keys.length > 0) { + selectedStrategyId = keys[0]; + } + + if (selectedStrategyId && strategies[selectedStrategyId]) { + renderStrategyCharts( + selectedStrategyId, + strategies[selectedStrategyId], + data + ); + highlightSelectedRow(selectedStrategyId); } } + // ------------------------------- // 4️⃣ Table // ------------------------------- @@ -834,19 +942,185 @@ function renderValidateResponse(data) { `; for (const r of data.results) { + html += ` - ${r.strategy_id} + + ${r.strategy_id} + ${r.status} ${r.oos_total_return_pct?.toFixed(2)} ${r.oos_max_dd_worst_pct?.toFixed(2)} ${r.n_windows} `; + + // 🔸 Mostrar warnings debajo de la fila si existen + if (r.warnings && r.warnings.length > 0) { + + html += ` + + + + + + `; + } } html += ""; wrap.innerHTML = html; + + document.querySelectorAll(".strategy-row").forEach(el => { + el.addEventListener("click", function () { + + console.log("Clicked:", selectedStrategyId); + + selectedStrategyId = this.dataset.strategy; + + if (!lastValidationResult?.series?.strategies[selectedStrategyId]) { + return; + } + + renderStrategyCharts( + selectedStrategyId, + lastValidationResult.series.strategies[selectedStrategyId], + lastValidationResult + ); + + highlightSelectedRow(selectedStrategyId); + }); + }); + } +} + +function renderStrategyCharts(strategyId, s, data) { + + if (!s) return + + // ============================ + // 1️⃣ EQUITY + // ============================ + + Plotly.newPlot("plot_equity", [{ + y: s.window_equity, + type: "scatter", + mode: "lines", + name: "Equity" + }], { + margin: { t: 20 }, + title: `Equity — ${strategyId}` + }); + + // ============================ + // 2️⃣ RETURNS + TRADES + // ============================ + + const ret = s.window_returns_pct || []; + const trd = s.window_trades || []; + + const retMax = Math.max(0, ...ret); + const retMin = Math.min(0, ...ret); + + const minTrades = data.config?.wf?.min_trades_test ?? 10; + + const trdMaxRaw = Math.max(0, ...trd); + const trdMax = Math.max(trdMaxRaw, minTrades); + + // Evitar divisiones raras + const retPosSpan = Math.max(1e-9, retMax); + const retNegSpan = Math.abs(retMin); + + // Alinear el 0 visualmente + const trdNegSpan = (retNegSpan / retPosSpan) * trdMax; + + const y1Range = [retMin, retMax]; + const y2Range = [-trdNegSpan, trdMax]; + + Plotly.newPlot("plot_returns", [ + + { + y: ret, + type: "bar", + name: "Return %", + marker: { color: "#3b82f6" }, + yaxis: "y1", + offsetgroup: "returns", + alignmentgroup: "group1" + }, + + { + y: trd, + type: "bar", + name: "Trades", + marker: { color: "#f59e0b" }, + yaxis: "y2", + offsetgroup: "trades", + alignmentgroup: "group1" + } + + ], { + + margin: { t: 20 }, + barmode: "group", + + yaxis: { + title: "Return %", + range: y1Range, + zeroline: true, + zerolinewidth: 2 + }, + + yaxis2: { + title: "Trades", + overlaying: "y", + side: "right", + range: y2Range, + zeroline: true, + zerolinewidth: 2 + }, + + shapes: [ + { + type: "line", + x0: -0.5, + x1: trd.length - 0.5, + y0: minTrades, + y1: minTrades, + yref: "y2", + line: { + color: "red", + width: 2, + dash: "dash" + } + } + ], + + legend: { + orientation: "h" + }, + + title: `Returns & Trades — ${strategyId}` + + }); +} + +function highlightSelectedRow(strategyId) { + + document.querySelectorAll(".strategy-row").forEach(el => { + el.style.backgroundColor = ""; + }); + + const active = document.querySelector( + `.strategy-row[data-strategy="${strategyId}"]` + ); + + if (active) { + active.style.backgroundColor = "#e6f0ff"; } } @@ -917,7 +1191,7 @@ async function validateStrategies() { 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"); @@ -1010,10 +1284,6 @@ function applyInheritedLock() { }); } -document.getElementById("lock_inherited") - .addEventListener("change", applyInheritedLock); - - async function init() { await loadStrategyCatalog(); addStrategySlot(); @@ -1037,4 +1307,7 @@ async function init() { }, 0); } +document.getElementById("lock_inherited") + .addEventListener("change", applyInheritedLock); + init(); diff --git a/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html index 350dc21..c34affd 100644 --- a/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html +++ b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html @@ -187,7 +187,7 @@
-

Walk-Forward & Optimization

+

Walk-Forward Validation (OOS)

@@ -203,26 +203,10 @@
-
- - -
- - -
-
- - -
-
- - + +
@@ -262,7 +246,7 @@
- Cada estrategia incluye un param_grid en JSON. + Cada estrategia utiliza parámetros fijos (validación OOS sin grid).
diff --git a/src/web/ui/v2/templates/pages/calibration/calibration_strategies.txt b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.txt new file mode 100644 index 0000000..350dc21 --- /dev/null +++ b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.txt @@ -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 %}