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 = ` +