diff --git a/scripts/research/compare_systems.py b/scripts/research/compare_systems.py index e07952f..7545b7d 100644 --- a/scripts/research/compare_systems.py +++ b/scripts/research/compare_systems.py @@ -23,7 +23,7 @@ from src.data.storage import StorageManager from src.risk.sizing.percent_risk import PercentRiskSizer from src.risk.stops.trailing_stop import TrailingStop -from src.strategies.moving_average import MovingAverageCrossover +from src.strategies.ma_crossover import MovingAverageCrossover from src.strategies.trend_filtered import TrendFilteredMACrossover from src.portfolio.portfolio_engine import PortfolioEngine diff --git a/scripts/research/portfolio_backtest.py b/scripts/research/portfolio_backtest.py index 72b97be..0d6ce9d 100644 --- a/scripts/research/portfolio_backtest.py +++ b/scripts/research/portfolio_backtest.py @@ -42,7 +42,7 @@ from src.data.storage import StorageManager from src.risk.sizing.percent_risk import PercentRiskSizer from src.risk.stops.trailing_stop import TrailingStop -from src.strategies.moving_average import MovingAverageCrossover +from src.strategies.ma_crossover import MovingAverageCrossover from src.strategies.trend_filtered import TrendFilteredMACrossover from src.portfolio.portfolio_engine import PortfolioEngine diff --git a/src/calibration/optimization_inspector.py b/src/calibration/optimization_inspector.py index 02dc4ce..a877d1d 100644 --- a/src/calibration/optimization_inspector.py +++ b/src/calibration/optimization_inspector.py @@ -21,9 +21,8 @@ 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 +from src.strategies.ma_crossover import MovingAverageCrossover +from src.strategies.rsi_reversion import RSIStrategy STRATEGY_REGISTRY = { @@ -35,10 +34,6 @@ STRATEGY_REGISTRY = { "class": RSIStrategy, "params": ["rsi_period", "overbought", "oversold"], }, - "buy_and_hold": { - "class": BuyAndHold, - "params": [], - }, } diff --git a/src/calibration/strategies_inspector.py b/src/calibration/strategies_inspector.py index c795324..1910fad 100644 --- a/src/calibration/strategies_inspector.py +++ b/src/calibration/strategies_inspector.py @@ -23,9 +23,8 @@ 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 +from src.strategies.ma_crossover import MovingAverageCrossover +from src.strategies.rsi_reversion import RSIStrategy # -------------------------------------------------- # Helpers @@ -278,10 +277,19 @@ def inspect_strategies_config( # Regime analysis is market-level (shared by all strategies for the same WF config) regime_cfg = TrendScoreConfig() + + if payload.strategies: + probe_sid = payload.strategies[0].strategy_id + probe_class = STRATEGY_REGISTRY.get(probe_sid, MovingAverageCrossover) + probe_params = dict(payload.strategies[0].parameters or {}) + else: + probe_class = MovingAverageCrossover + probe_params = {} + wf_probe = WalkForwardValidator( - strategy_class=BuyAndHold, + strategy_class=probe_class, param_grid=None, - fixed_params={}, + fixed_params=probe_params, data=df, train_window=train_td, test_window=test_td, @@ -293,6 +301,7 @@ def inspect_strategies_config( stop_loss=stop_loss, verbose=False, ) + wf_windows = wf_probe._generate_windows() regime_bundle = compute_regimes_for_windows(df, wf_windows, config=regime_cfg) regime_by_window = {int(r["window"]): r for r in regime_bundle["by_window"]} diff --git a/src/calibration/strategy_promotion.py b/src/calibration/strategy_promotion.py index 7d1acaf..cb18658 100644 --- a/src/calibration/strategy_promotion.py +++ b/src/calibration/strategy_promotion.py @@ -1,23 +1,30 @@ -#src/calibration/strategy_promotion.py -from typing import List, Dict, Any +# src/calibration/strategy_promotion.py +from typing import List, Dict, Any, Optional +import math + def _clamp(v, lo, hi): return max(lo, min(hi, v)) + def _score_return(oos_return): return _clamp((oos_return / 50.0) * 30.0, 0, 30) + def _score_stability(positive_rate, std_return): score = positive_rate * 15 score += _clamp(10 - std_return, 0, 10) return _clamp(score, 0, 25) + def _score_risk(worst_dd): return _clamp((1 + worst_dd / 20.0) * 20.0, 0, 20) + def _score_trades(avg_trades): return _clamp(avg_trades / 10.0 * 10.0, 0, 10) + def _score_regime(regime_detail: Dict[str, Any]): if not regime_detail: return 0 @@ -25,17 +32,66 @@ def _score_regime(regime_detail: Dict[str, Any]): total = len(regime_detail) return (positives / total) * 15 if total else 0 -def evaluate_strategy(strategy: Dict[str, Any], config: Dict[str, Any]): - metrics = strategy.get("diagnostics", {}) - stability = metrics.get("stability", {}) - trades = metrics.get("trades", {}) - regimes = metrics.get("regimes", {}).get("performance", {}).get("detail", {}) - oos_return = strategy.get("oos_total_return_pct", 0) - worst_dd = strategy.get("oos_max_dd_worst_pct", -100) - positive_rate = stability.get("positive_window_rate", 0) - std_return = stability.get("std_return_pct", 0) - avg_trades = trades.get("avg_trades_per_window", 0) +def _extract_window_returns(strategy: Dict[str, Any]) -> List[float]: + windows = strategy.get("windows", []) or [] + out = [] + + for w in windows: + if not isinstance(w, dict): + continue + + v = w.get("return_pct", None) + if v is None: + continue + + try: + out.append(float(v)) + except (TypeError, ValueError): + continue + + return out + + +def _pearson_corr(a: List[float], b: List[float]) -> Optional[float]: + n = min(len(a), len(b)) + if n < 2: + return None + + x = a[:n] + y = b[:n] + + mx = sum(x) / n + my = sum(y) / n + + num = 0.0 + den_x = 0.0 + den_y = 0.0 + + for xi, yi in zip(x, y): + dx = xi - mx + dy = yi - my + num += dx * dy + den_x += dx * dx + den_y += dy * dy + + if den_x <= 0 or den_y <= 0: + return None + + return num / math.sqrt(den_x * den_y) + + +def evaluate_strategy(strategy: Dict[str, Any], config: Dict[str, Any]): + metrics = strategy.get("diagnostics", {}) or {} + stability = metrics.get("stability", {}) or {} + trades = metrics.get("trades", {}) or {} + regimes = metrics.get("regimes", {}).get("performance", {}).get("detail", {}) or {} + + oos_return = float(strategy.get("oos_total_return_pct", 0) or 0) + worst_dd = float(strategy.get("oos_max_dd_worst_pct", -100) or -100) + positive_rate = float(stability.get("positive_window_rate", 0) or 0) + std_return = float(stability.get("std_return_pct", 0) or 0) + avg_trades = float(trades.get("avg_trades_per_window", 0) or 0) score = 0 score += _score_return(oos_return) @@ -58,11 +114,64 @@ def evaluate_strategy(strategy: Dict[str, Any], config: Dict[str, Any]): "strategy_id": strategy.get("strategy_id"), "score": round(score, 2), "status": status, + "diversity_blocked_by": None, + "diversity_correlation": None, } + +def _apply_diversity_filter( + ranked: List[Dict[str, Any]], + original_map: Dict[str, Dict[str, Any]], + max_corr: float, +) -> List[Dict[str, Any]]: + selected_promotes: List[Dict[str, Any]] = [] + + for item in ranked: + if item.get("status") != "promote": + continue + + strategy_id = item.get("strategy_id") + current_strategy = original_map.get(strategy_id, {}) + current_returns = _extract_window_returns(current_strategy) + + blocked = False + blocked_by = None + blocked_corr = None + + for selected in selected_promotes: + selected_id = selected.get("strategy_id") + selected_strategy = original_map.get(selected_id, {}) + selected_returns = _extract_window_returns(selected_strategy) + + corr = _pearson_corr(current_returns, selected_returns) + if corr is None: + continue + + if abs(corr) >= max_corr: + blocked = True + blocked_by = selected_id + blocked_corr = round(corr, 4) + break + + if blocked: + item["status"] = "review_diversity" + item["diversity_blocked_by"] = blocked_by + item["diversity_correlation"] = blocked_corr + else: + selected_promotes.append(item) + + return ranked + + def rank_strategies(strategies: List[Dict[str, Any]], config: Dict[str, Any]): evaluated = [evaluate_strategy(s, config) for s in strategies] evaluated.sort(key=lambda x: x["score"], reverse=True) + for i, e in enumerate(evaluated): e["rank"] = i + 1 + + max_corr = float(config.get("max_strategy_correlation", 0.85) or 0.85) + original_map = {s.get("strategy_id"): s for s in strategies if s.get("strategy_id")} + + evaluated = _apply_diversity_filter(evaluated, original_map, max_corr=max_corr) return evaluated \ No newline at end of file diff --git a/src/core/strategy.py b/src/core/strategy.py index 8c39e9d..0d8b721 100644 --- a/src/core/strategy.py +++ b/src/core/strategy.py @@ -155,25 +155,35 @@ def calculate_macd(data: pd.Series, fast: int = 12, slow: int = 26, signal: int return macd_line, signal_line, histogram -def calculate_bollinger_bands(data: pd.Series, period: int = 20, std_dev: float = 2) -> tuple: +def calculate_bollinger_bands( + data: pd.Series, + period: int = 20, + std_dev: float = 2.0, +) -> tuple[pd.Series, pd.Series, pd.Series]: """ - Bollinger Bands - - Args: - data: Serie de precios - period: Periodo de la media móvil - std_dev: Número de desviaciones estándar - - Returns: - Tupla (Upper Band, Middle Band, Lower Band) + Calcula Bollinger Bands. + + Devuelve: + - mid_band: media móvil simple + - upper_band: mid + std_dev * rolling_std + - lower_band: mid - std_dev * rolling_std + + Notas: + - Usa std(ddof=0) para mantener consistencia con el cálculo rolling poblacional. + - Requiere al menos `period` barras para producir valores no NaN. """ - middle = calculate_sma(data, period) - std = data.rolling(window=period).std() - - upper = middle + (std * std_dev) - lower = middle - (std * std_dev) - - return upper, middle, lower + if period <= 0: + raise ValueError("period must be > 0") + if std_dev <= 0: + raise ValueError("std_dev must be > 0") + + mid_band = data.rolling(window=period).mean() + rolling_std = data.rolling(window=period).std(ddof=0) + + upper_band = mid_band + (std_dev * rolling_std) + lower_band = mid_band - (std_dev * rolling_std) + + return mid_band, upper_band, lower_band def calculate_atr(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14) -> pd.Series: """ @@ -195,4 +205,34 @@ def calculate_atr(high: pd.Series, low: pd.Series, close: pd.Series, period: int tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) atr = tr.rolling(window=period).mean() - return atr \ No newline at end of file + return atr + +def calculate_roc(data: pd.Series, period: int = 10) -> pd.Series: + """ + Rate of Change (ROC) como variacion porcentual respecto a N barras atras. + """ + return data.pct_change(period) + +def calculate_donchian_channels(high: pd.Series, low: pd.Series, period: int = 20, shift: int = 1) -> tuple[pd.Series, pd.Series]: + """ + Canal Donchian: + - upper: máximo rolling de high + - lower: mínimo rolling de low + + shift=1 evita usar la barra actual para confirmar el breakout. + """ + upper = high.rolling(window=period).max().shift(shift) + lower = low.rolling(window=period).min().shift(shift) + return upper, lower + +def cross_above(series_a: pd.Series, series_b: pd.Series) -> pd.Series: + """ + True cuando series_a cruza por encima de series_b. + """ + return (series_a > series_b) & (series_a.shift(1) <= series_b.shift(1)) + +def cross_below(series_a: pd.Series, series_b: pd.Series) -> pd.Series: + """ + True cuando series_a cruza por debajo de series_b. + """ + return (series_a < series_b) & (series_a.shift(1) >= series_b.shift(1)) \ No newline at end of file diff --git a/src/strategies/__init__.py b/src/strategies/__init__.py index 1d997bb..c224705 100644 --- a/src/strategies/__init__.py +++ b/src/strategies/__init__.py @@ -2,9 +2,8 @@ """ Colección de estrategias de trading """ -from .moving_average import MovingAverageCrossover -from .buy_and_hold import BuyAndHold -from .rsi_strategy import RSIStrategy +from .ma_crossover import MovingAverageCrossover +from .rsi_reversion import RSIStrategy __all__ = [ 'MovingAverageCrossover', diff --git a/src/strategies/bollinger_reversion.py b/src/strategies/bollinger_reversion.py new file mode 100644 index 0000000..a4554b0 --- /dev/null +++ b/src/strategies/bollinger_reversion.py @@ -0,0 +1,136 @@ +# src/strategies/bollinger_reversion.py +""" +Estrategia de reversión a la media con Bollinger Bands. +""" +from __future__ import annotations + +import pandas as pd + +from ..core.strategy import Strategy, Signal, calculate_bollinger_bands, cross_above, cross_below + +class BollingerReversion(Strategy): + """ + Mean reversion basada en Bollinger Bands. + + Señales: + - BUY: close cruza por debajo de la banda inferior + - SELL: close cruza por encima de la banda superior + - HOLD: resto de casos + """ + + strategy_id = "bollinger_reversion" + strategy_family = "mean_reversion" + display_name = "Bollinger Reversion" + description = "Reversión a la media usando Bollinger Bands." + + def __init__( + self, + bb_period: int = 20, + bb_std: float = 2.0, + exit_on_mid: bool = True, + ): + params = { + "bb_period": bb_period, + "bb_std": bb_std, + "exit_on_mid": exit_on_mid, + } + super().__init__(name="Bollinger Reversion", params=params) + + self.bb_period = int(bb_period) + self.bb_std = float(bb_std) + self.exit_on_mid = bool(exit_on_mid) + + @classmethod + def default_parameters(cls) -> dict: + return { + "bb_period": 20, + "bb_std": 2.0, + "exit_on_mid": True, + } + + @classmethod + def strategy_metadata(cls) -> dict: + return { + "strategy_id": cls.strategy_id, + "name": cls.display_name, + "family": cls.strategy_family, + "direction": "long_short", + "description": cls.description, + } + + @classmethod + def strategy_definition(cls) -> dict: + return { + "meta": cls.strategy_metadata(), + "defaults": cls.default_parameters(), + "parameters_schema": cls.parameters_schema(), + } + + + @classmethod + def parameters_schema(cls) -> dict: + return { + "bb_period": { + "type": "int", + "min": 2, + "max": 500, + "default": 20, + }, + "bb_std": { + "type": "float", + "min": 0.1, + "max": 5.0, + "default": 2.0, + }, + "exit_on_mid": { + "type": "bool", + "default": True, + }, + } + + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + close = data["close"] + + bb_mid, bb_upper, bb_lower = calculate_bollinger_bands( + close, + period=self.bb_period, + std_dev=self.bb_std, + ) + + data["bb_mid"] = bb_mid + data["bb_upper"] = bb_upper + data["bb_lower"] = bb_lower + + data["cross_below_lower"] = cross_below(close, data["bb_lower"]) + data["cross_above_upper"] = cross_above(close, data["bb_upper"]) + data["cross_above_mid"] = cross_above(close, data["bb_mid"]) + data["cross_below_mid"] = cross_below(close, data["bb_mid"]) + + return data + + def generate_signal(self, idx: int) -> Signal: + if self.data is None: + raise ValueError("Data no establecida") + + if idx < 1: + return Signal.HOLD + + row = self.data.iloc[idx] + + needed = ["bb_mid", "bb_upper", "bb_lower"] + if any(pd.isna(row[c]) for c in needed): + return Signal.HOLD + + if bool(row["cross_below_lower"]) and self.current_position <= 0: + return Signal.BUY + + if bool(row["cross_above_upper"]) and self.current_position >= 0: + return Signal.SELL + + if self.exit_on_mid: + if self.current_position > 0 and bool(row["cross_above_mid"]): + return Signal.SELL + if self.current_position < 0 and bool(row["cross_below_mid"]): + return Signal.BUY + + return Signal.HOLD diff --git a/src/strategies/breakout.py b/src/strategies/breakout.py deleted file mode 100644 index 426a4ff..0000000 --- a/src/strategies/breakout.py +++ /dev/null @@ -1,64 +0,0 @@ -# src/strategies/breakout.py -import pandas as pd - -from src.strategies.base import Strategy -from src.core.strategy import Signal - -class DonchianBreakout(Strategy): - """ - Estrategia de ruptura de canales Donchian - - Señales: - - BUY: El precio rompe el máximo de los últimos N periodos - - SELL: El precio rompe el mínimo de los últimos N periodos - - HOLD: En cualquier otro caso - - Parámetros: - lookback: Ventana de cálculo del canal - - Valores por defecto: - lookback = 20 - ≈ 1 día en timeframe 1h - Parámetro clásico del sistema Turtle - - Notas: - - Es una estrategia de momentum puro - - No intenta comprar barato, compra fortaleza - - Filtra ruido al exigir ruptura real - """ - - def __init__(self, lookback: int = 20): - params = { - "lookback": lookback - } - - super().__init__(name="DonchianBreakout", params=params) - - self.lookback = lookback - - # ------------------------------------------------------------------ - - def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: - data["donchian_high"] = data["high"].rolling(self.lookback).max() - data["donchian_low"] = data["low"].rolling(self.lookback).min() - return data - - def generate_signal(self, idx: int) -> Signal: - if idx < self.lookback: - return Signal.HOLD - - high = self.data["high"] - low = self.data["low"] - close = self.data["close"] - - max_high = high.iloc[idx - self.lookback : idx].max() - min_low = low.iloc[idx - self.lookback : idx].min() - - price = close.iloc[idx] - - if price > max_high: - return Signal.BUY - elif price < min_low: - return Signal.SELL - - return Signal.HOLD diff --git a/src/strategies/buy_and_hold.py b/src/strategies/buy_and_hold.py deleted file mode 100644 index 1978b41..0000000 --- a/src/strategies/buy_and_hold.py +++ /dev/null @@ -1,40 +0,0 @@ -# src/strategies/buy_and_hold.py -""" -Estrategia Buy and Hold -""" -import pandas as pd -from ..core.strategy import Strategy, Signal - -class BuyAndHold(Strategy): - """ - Estrategia simple Buy and Hold - - Compra al inicio y mantiene hasta el final. - Útil como baseline para comparar otras estrategias. - """ - - def __init__(self): - super().__init__(name="Buy and Hold", params={}) - self.bought = False - - def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: - """ - No necesita indicadores - """ - return data - - def generate_signal(self, idx: int) -> Signal: - """ - Compra solo la primera vez, luego mantiene - """ - if not self.bought: - self.bought = True - return Signal.BUY - - return Signal.HOLD - - def reset(self): - """ - Reinicia el estado para nuevo backtest - """ - self.bought = False \ No newline at end of file diff --git a/src/strategies/demo_pingpong.py b/src/strategies/demo_pingpong.py deleted file mode 100644 index 608d52c..0000000 --- a/src/strategies/demo_pingpong.py +++ /dev/null @@ -1,35 +0,0 @@ -from src.core.strategy import Signal -from src.utils.logger import log - - -class DemoPingPongStrategy: - """ - Estrategia DEMO para testear UI. - Genera BUY / SELL cada N ticks del loop. - """ - - def __init__(self, period: int = 3): - self.period = period - self.name = "demo" - self.data = None - self.tick = 0 # 👈 CLAVE - - def set_data(self, df): - self.data = df - - def generate_signal(self, idx: int) -> Signal: - self.tick += 1 - - log.info(f"[PINGPONG] tick={self.tick}") - - if self.tick == 3: - log.info("[PINGPONG] BUY signal") - return Signal.BUY - - if self.tick == 5: - log.info("[PINGPONG] SELL signal") - self.tick = 0 - return Signal.SELL - - return Signal.HOLD - diff --git a/src/strategies/donchian_breakout.py b/src/strategies/donchian_breakout.py new file mode 100644 index 0000000..2ef0941 --- /dev/null +++ b/src/strategies/donchian_breakout.py @@ -0,0 +1,130 @@ +#src/strategies/donchian_breakout.py +""" +Estrategia de breakout de rango Donchian. +""" +from __future__ import annotations + +import pandas as pd + +from ..core.strategy import Strategy, Signal, calculate_donchian_channels, cross_above, cross_below + + +class DonchianBreakout(Strategy): + """ + Breakout de máximos/mínimos de N barras. + + Señales: + - BUY: close rompe por encima del máximo Donchian previo + - SELL: close rompe por debajo del mínimo Donchian previo + """ + + strategy_id = "donchian_breakout" + strategy_family = "breakout" + display_name = "Donchian Breakout" + description = "Breakout de rango basado en máximos y mínimos rolling." + + def __init__(self, donchian_window: int = 20, exit_window: int = 10): + params = { + "donchian_window": donchian_window, + "exit_window": exit_window, + } + super().__init__(name="Donchian Breakout", params=params) + + self.donchian_window = int(donchian_window) + self.exit_window = int(exit_window) + + @classmethod + def default_parameters(cls) -> dict: + return { + "donchian_window": 20, + "exit_window": 10, + } + + @classmethod + def strategy_metadata(cls) -> dict: + return { + "strategy_id": cls.strategy_id, + "name": cls.display_name, + "family": cls.strategy_family, + "direction": "long_short", + "description": cls.description, + } + + @classmethod + def strategy_definition(cls) -> dict: + return { + "meta": cls.strategy_metadata(), + "defaults": cls.default_parameters(), + "parameters_schema": cls.parameters_schema(), + } + + @classmethod + def parameters_schema(cls) -> dict: + return { + "donchian_window": { + "type": "int", + "min": 2, + "max": 300, + "default": 20, + }, + "exit_window": { + "type": "int", + "min": 2, + "max": 300, + "default": 10, + }, + } + + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + high = data["high"] + low = data["low"] + close = data["close"] + + data["donchian_high"], data["donchian_low"] = calculate_donchian_channels( + high, + low, + period=self.donchian_window, + shift=1, + ) + + data["exit_high"], data["exit_low"] = calculate_donchian_channels( + high, + low, + period=self.exit_window, + shift=1, + ) + + data["breakout_up"] = cross_above(close, data["donchian_high"]) + data["breakout_down"] = cross_below(close, data["donchian_low"]) + + data["lose_exit_low"] = cross_below(close, data["exit_low"]) + data["lose_exit_high"] = cross_above(close, data["exit_high"]) + + return data + + def generate_signal(self, idx: int) -> Signal: + if self.data is None: + raise ValueError("Data no establecida") + + if idx < 1: + return Signal.HOLD + + row = self.data.iloc[idx] + + needed = ["donchian_high", "donchian_low", "exit_high", "exit_low"] + if any(pd.isna(row[c]) for c in needed): + return Signal.HOLD + + if bool(row["breakout_up"]) and self.current_position <= 0: + return Signal.BUY + + if bool(row["breakout_down"]) and self.current_position >= 0: + return Signal.SELL + + if self.current_position > 0 and bool(row["lose_exit_low"]): + return Signal.SELL + + if self.current_position < 0 and bool(row["lose_exit_high"]): + return Signal.BUY + + return Signal.HOLD diff --git a/src/strategies/moving_average.py b/src/strategies/ma_crossover.py similarity index 83% rename from src/strategies/moving_average.py rename to src/strategies/ma_crossover.py index df5d1d7..d2f771c 100644 --- a/src/strategies/moving_average.py +++ b/src/strategies/ma_crossover.py @@ -10,12 +10,6 @@ class MovingAverageCrossover(Strategy): """ Estrategia de cruce de medias móviles - 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 @@ -34,6 +28,9 @@ class MovingAverageCrossover(Strategy): """ strategy_id = "moving_average" + strategy_family = "trend_following" + display_name = "Moving Average Crossover" + description = "Cruce de medias móviles con filtro ADX opcional." def __init__( self, @@ -62,6 +59,38 @@ class MovingAverageCrossover(Strategy): if self.ma_type not in ['sma', 'ema']: raise ValueError("ma_type debe ser 'sma' o 'ema'") + + @classmethod + def default_parameters(cls) -> dict: + return { + "fast_period": 20, + "slow_period": 50, + "ma_type": "ema", + "use_adx": False, + "adx_threshold": 20.0, + } + + + @classmethod + def strategy_metadata(cls) -> dict: + return { + "strategy_id": cls.strategy_id, + "name": cls.display_name, + "family": cls.strategy_family, + "direction": "long_short", + "description": cls.description, + } + + + @classmethod + def strategy_definition(cls) -> dict: + return { + "meta": cls.strategy_metadata(), + "defaults": cls.default_parameters(), + "parameters_schema": cls.parameters_schema(), + } + + @classmethod def parameters_schema(cls) -> dict: return { diff --git a/src/strategies/mean_reversion.py b/src/strategies/mean_reversion.py deleted file mode 100644 index fbda5fe..0000000 --- a/src/strategies/mean_reversion.py +++ /dev/null @@ -1,95 +0,0 @@ -# src/strategies/mean_reversion.py - -import pandas as pd -import numpy as np - -from src.strategies.base import Strategy -from src.core.strategy import Signal - - -class RSIMeanReversion(Strategy): - """ - Estrategia de reversión a la media basada en RSI. - - Idea: - - Compra cuando el mercado está sobrevendido - - Vende cuando el precio rebota hacia la media - - Señales: - - BUY: RSI cruza por debajo de oversold - - SELL: RSI cruza por encima de overbought - - HOLD: en cualquier otro caso - - Parámetros: - period: periodo del RSI - oversold: nivel de sobreventa - overbought: nivel de sobrecompra - - Valores típicos: - period = 14 - oversold = 30 - overbought = 70 - """ - - def __init__( - self, - period: int = 14, - oversold: float = 30.0, - overbought: float = 70.0, - ): - super().__init__( - name="RSI_MeanReversion", - params={ - "period": period, - "oversold": oversold, - "overbought": overbought, - }, - ) - - self.period = period - self.oversold = oversold - self.overbought = overbought - - # -------------------------------------------------- - def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: - """ - Calcula el RSI clásico (Wilder). - - Añade: - - data["rsi"] - """ - delta = data["close"].diff() - - gain = delta.clip(lower=0) - loss = -delta.clip(upper=0) - - avg_gain = gain.ewm(alpha=1 / self.period, adjust=False).mean() - avg_loss = loss.ewm(alpha=1 / self.period, adjust=False).mean() - - rs = avg_gain / avg_loss - rsi = 100 - (100 / (1 + rs)) - - data["rsi"] = rsi - - return data - - # -------------------------------------------------- - def generate_signal(self, idx: int) -> Signal: - """ - Genera señales de trading basadas en cruces del RSI. - """ - if idx == 0: - return Signal.HOLD - - rsi_prev = self.data["rsi"].iloc[idx - 1] - rsi_curr = self.data["rsi"].iloc[idx] - - # BUY → cruce hacia abajo de oversold - if rsi_prev > self.oversold and rsi_curr <= self.oversold: - return Signal.BUY - - # SELL → cruce hacia arriba de overbought - if rsi_prev < self.overbought and rsi_curr >= self.overbought: - return Signal.SELL - - return Signal.HOLD diff --git a/src/strategies/ml_model.py b/src/strategies/ml_model.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/strategies/optimization/opt_moving_average.py b/src/strategies/optimization/opt_moving_average.py index 445fb16..5bb07af 100644 --- a/src/strategies/optimization/opt_moving_average.py +++ b/src/strategies/optimization/opt_moving_average.py @@ -1,5 +1,5 @@ from itertools import product -from src.strategies.moving_average import MovingAverageCrossover +from src.strategies.ma_crossover import MovingAverageCrossover class MACrossoverOptimization: diff --git a/src/strategies/optimization/opt_trend_filtered.py b/src/strategies/optimization/opt_trend_filtered.py deleted file mode 100644 index d975f9d..0000000 --- a/src/strategies/optimization/opt_trend_filtered.py +++ /dev/null @@ -1,26 +0,0 @@ -from itertools import product -from src.strategies.trend_filtered import TrendFilteredMACrossover - -class TrendFilteredMAOptimization: - - name = "TrendFiltered_MA" - - @staticmethod - def parameter_grid(): - fast = [10, 15, 20, 25, 30] - slow = [40, 50, 60, 80, 100] - adx = [15, 20, 25, 30] - min_gap = 15 - - for f, s, a in product(fast, slow, adx): - if s - f >= min_gap: - yield { - "fast_period": f, - "slow_period": s, - "ma_type": "ema", - "adx_threshold": a, - } - - @staticmethod - def build_strategy(params): - return TrendFilteredMACrossover(**params) diff --git a/src/strategies/regime_filtered_trend.py b/src/strategies/regime_filtered_trend.py new file mode 100644 index 0000000..accc3e9 --- /dev/null +++ b/src/strategies/regime_filtered_trend.py @@ -0,0 +1,169 @@ +#src/strategies/regime_filtered_trend.py +""" +Estrategia trend-following con filtro de régimen interno. +""" +from __future__ import annotations + +import pandas as pd + +from ..core.strategy import Strategy, Signal, calculate_sma, calculate_ema + + +class RegimeFilteredTrend(Strategy): + """ + Trend following con cruce de medias y filtro de régimen simple. + """ + + strategy_id = "regime_filtered_trend" + strategy_family = "regime_aware" + display_name = "Regime Filtered Trend" + description = "Cruce de medias con filtro de régimen basado en EMAs." + + def __init__( + self, + fast_period: int = 20, + slow_period: int = 50, + ma_type: str = "ema", + long_regime_min_score: int = 2, + short_regime_max_score: int = -2, + ): + params = { + "fast_period": fast_period, + "slow_period": slow_period, + "ma_type": ma_type, + "long_regime_min_score": long_regime_min_score, + "short_regime_max_score": short_regime_max_score, + } + super().__init__(name="Regime Filtered Trend", params=params) + + self.fast_period = int(fast_period) + self.slow_period = int(slow_period) + self.ma_type = str(ma_type).lower() + self.long_regime_min_score = int(long_regime_min_score) + self.short_regime_max_score = int(short_regime_max_score) + + if self.ma_type not in ["sma", "ema"]: + raise ValueError("ma_type debe ser 'sma' o 'ema'") + + @classmethod + def default_parameters(cls) -> dict: + return { + "fast_period": 20, + "slow_period": 50, + "ma_type": "ema", + "long_regime_min_score": 2, + "short_regime_max_score": -2, + } + + @classmethod + def strategy_metadata(cls) -> dict: + return { + "strategy_id": cls.strategy_id, + "name": cls.display_name, + "family": cls.strategy_family, + "direction": "long_short", + "description": cls.description, + } + + @classmethod + def strategy_definition(cls) -> dict: + return { + "meta": cls.strategy_metadata(), + "defaults": cls.default_parameters(), + "parameters_schema": cls.parameters_schema(), + } + + @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", + }, + "long_regime_min_score": { + "type": "int", + "min": -4, + "max": 4, + "default": 2, + }, + "short_regime_max_score": { + "type": "int", + "min": -4, + "max": 4, + "default": -2, + }, + } + + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + close = data["close"] + + if self.ma_type == "sma": + data["ma_fast"] = calculate_sma(close, self.fast_period) + data["ma_slow"] = calculate_sma(close, self.slow_period) + else: + data["ma_fast"] = calculate_ema(close, self.fast_period) + data["ma_slow"] = calculate_ema(close, self.slow_period) + + data["ema_20_regime"] = calculate_ema(close, 20) + data["ema_50_regime"] = calculate_ema(close, 50) + data["ema_100_regime"] = calculate_ema(close, 100) + data["ema_200_regime"] = calculate_ema(close, 200) + + score = ( + (close > data["ema_20_regime"]).astype(int).replace({0: -1}) + + (close > data["ema_50_regime"]).astype(int).replace({0: -1}) + + (close > data["ema_100_regime"]).astype(int).replace({0: -1}) + + (close > data["ema_200_regime"]).astype(int).replace({0: -1}) + ) + + data["regime_score"] = score + + data["ma_cross"] = 0 + data.loc[data["ma_fast"] > data["ma_slow"], "ma_cross"] = 1 + data.loc[data["ma_fast"] < data["ma_slow"], "ma_cross"] = -1 + data["ma_cross_change"] = data["ma_cross"].diff() + + return data + + def generate_signal(self, idx: int) -> Signal: + if self.data is None: + raise ValueError("Data no establecida") + + if idx < 1: + return Signal.HOLD + + row = self.data.iloc[idx] + + needed = ["ma_fast", "ma_slow", "regime_score", "ma_cross_change"] + if any(pd.isna(row[c]) for c in needed): + return Signal.HOLD + + cross_change = row["ma_cross_change"] + regime_score = int(row["regime_score"]) + + if cross_change == 2 and regime_score >= self.long_regime_min_score and self.current_position <= 0: + return Signal.BUY + + if cross_change == -2 and regime_score <= self.short_regime_max_score and self.current_position >= 0: + return Signal.SELL + + if self.current_position > 0 and regime_score < self.long_regime_min_score: + return Signal.SELL + + if self.current_position < 0 and regime_score > self.short_regime_max_score: + return Signal.BUY + + return Signal.HOLD diff --git a/src/strategies/registry.py b/src/strategies/registry.py index baa0c90..7605982 100644 --- a/src/strategies/registry.py +++ b/src/strategies/registry.py @@ -1,12 +1,19 @@ # src/strategies/registry.py -from .moving_average import MovingAverageCrossover -from .rsi_strategy import RSIStrategy -from .buy_and_hold import BuyAndHold +from .ma_crossover import MovingAverageCrossover +from .rsi_reversion import RSIStrategy +from .bollinger_reversion import BollingerReversion +from .donchian_breakout import DonchianBreakout +from .roc_momentum import ROCMomentum +from .regime_filtered_trend import RegimeFilteredTrend ALL_STRATEGIES = [ MovingAverageCrossover, RSIStrategy, + BollingerReversion, + DonchianBreakout, + ROCMomentum, + RegimeFilteredTrend, ] diff --git a/src/strategies/roc_momentum.py b/src/strategies/roc_momentum.py new file mode 100644 index 0000000..5b1c72b --- /dev/null +++ b/src/strategies/roc_momentum.py @@ -0,0 +1,138 @@ +#src/strategies/roc_momentum.py +""" +Estrategia de momentum basada en Rate of Change. +""" +from __future__ import annotations + +import pandas as pd + +from ..core.strategy import Strategy, Signal, calculate_roc + + +class ROCMomentum(Strategy): + """ + Momentum puro basado en ROC. + + Señales: + - BUY: ROC cruza por encima del umbral positivo + - SELL: ROC cruza por debajo del umbral negativo + """ + + strategy_id = "roc_momentum" + strategy_family = "momentum" + display_name = "ROC Momentum" + description = "Momentum basado en Rate of Change." + + def __init__( + self, + roc_window: int = 10, + roc_threshold: float = 0.02, + exit_threshold: float = 0.0, + ): + params = { + "roc_window": roc_window, + "roc_threshold": roc_threshold, + "exit_threshold": exit_threshold, + } + super().__init__(name="ROC Momentum", params=params) + + self.roc_window = int(roc_window) + self.roc_threshold = float(roc_threshold) + self.exit_threshold = float(exit_threshold) + + @classmethod + def default_parameters(cls) -> dict: + return { + "roc_window": 10, + "roc_threshold": 0.02, + "exit_threshold": 0.0, + } + + @classmethod + def strategy_metadata(cls) -> dict: + return { + "strategy_id": cls.strategy_id, + "name": cls.display_name, + "family": cls.strategy_family, + "direction": "long_short", + "description": cls.description, + } + + @classmethod + def strategy_definition(cls) -> dict: + return { + "meta": cls.strategy_metadata(), + "defaults": cls.default_parameters(), + "parameters_schema": cls.parameters_schema(), + } + + @classmethod + def parameters_schema(cls) -> dict: + return { + "roc_window": { + "type": "int", + "min": 1, + "max": 300, + "default": 10, + }, + "roc_threshold": { + "type": "float", + "min": 0.0, + "max": 1.0, + "default": 0.02, + }, + "exit_threshold": { + "type": "float", + "min": -1.0, + "max": 1.0, + "default": 0.0, + }, + } + + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + data["roc"] = calculate_roc(data["close"], self.roc_window) + + data["roc_cross_up"] = ( + (data["roc"] > self.roc_threshold) & + (data["roc"].shift(1) <= self.roc_threshold) + ) + data["roc_cross_down"] = ( + (data["roc"] < -self.roc_threshold) & + (data["roc"].shift(1) >= -self.roc_threshold) + ) + + data["roc_exit_long"] = ( + (data["roc"] < self.exit_threshold) & + (data["roc"].shift(1) >= self.exit_threshold) + ) + data["roc_exit_short"] = ( + (data["roc"] > -self.exit_threshold) & + (data["roc"].shift(1) <= -self.exit_threshold) + ) + + return data + + def generate_signal(self, idx: int) -> Signal: + if self.data is None: + raise ValueError("Data no establecida") + + if idx < 1: + return Signal.HOLD + + row = self.data.iloc[idx] + if pd.isna(row["roc"]): + return Signal.HOLD + + if bool(row["roc_cross_up"]) and self.current_position <= 0: + return Signal.BUY + + if bool(row["roc_cross_down"]) and self.current_position >= 0: + return Signal.SELL + + if self.current_position > 0 and bool(row["roc_exit_long"]): + return Signal.SELL + + if self.current_position < 0 and bool(row["roc_exit_short"]): + return Signal.BUY + + return Signal.HOLD diff --git a/src/strategies/rsi_strategy.py b/src/strategies/rsi_reversion.py similarity index 74% rename from src/strategies/rsi_strategy.py rename to src/strategies/rsi_reversion.py index f6d5b5e..8e19015 100644 --- a/src/strategies/rsi_strategy.py +++ b/src/strategies/rsi_reversion.py @@ -21,6 +21,9 @@ class RSIStrategy(Strategy): """ strategy_id = "rsi" + strategy_family = "mean_reversion" + display_name = "RSI Reversion" + description = "Mean reversion basada en niveles de sobrecompra y sobreventa del RSI." def __init__(self, rsi_period: int = 14, oversold: float = 30, overbought: float = 70): @@ -36,6 +39,34 @@ class RSIStrategy(Strategy): self.oversold = oversold self.overbought = overbought + + @classmethod + def default_parameters(cls) -> dict: + return { + "rsi_period": 14, + "oversold": 30, + "overbought": 70, + } + + @classmethod + def strategy_metadata(cls) -> dict: + return { + "strategy_id": cls.strategy_id, + "name": cls.display_name, + "family": cls.strategy_family, + "direction": "long_short", + "description": cls.description, + } + + @classmethod + def strategy_definition(cls) -> dict: + return { + "meta": cls.strategy_metadata(), + "defaults": cls.default_parameters(), + "parameters_schema": cls.parameters_schema(), + } + + @classmethod def parameters_schema(cls) -> dict: return { diff --git a/src/strategies/signals.py b/src/strategies/signals.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/strategies/strategy_template.py b/src/strategies/strategy_template.py new file mode 100644 index 0000000..09b7d1c --- /dev/null +++ b/src/strategies/strategy_template.py @@ -0,0 +1,154 @@ +#src/strategies/strategy_template.py +from __future__ import annotations + +from typing import Any, Dict + +import pandas as pd + +from .base import BaseStrategy +from ..core.strategy import Strategy, Signal + + +class TemplateStrategy(Strategy): + """ + Plantilla base para nuevas estrategias del strategy_repository. + + Objetivo: + - Servir como archivo de referencia para copiar/pegar. + - Mantener compatibilidad con el framework actual. + - Estandarizar metadatos, defaults y parameters_schema. + - No se debe registrar ni usar en producción tal cual. + + Contrato esperado por el framework actual: + - strategy_id + - parameters_schema() + - init_indicators() + - generate_signal() + + Contrato adicional recomendado para el repository: + - default_parameters() + - strategy_metadata() + - strategy_definition() + """ + + strategy_id = "template_strategy" + strategy_family = "template" + display_name = "Template Strategy" + description = "Plantilla de referencia para crear nuevas estrategias." + + def __init__(self, **params): + """ + Aquí define los parámetros que realmente usará la estrategia. + + Recomendación: + - usar params.get(...) con defaults explícitos + - castear tipos si hace falta + - no meter lógica pesada aquí + """ + self.example_period = int(params.get("example_period", 20)) + self.example_threshold = float(params.get("example_threshold", 0.0)) + + @classmethod + def default_parameters(cls) -> Dict[str, Any]: + """ + Parámetros por defecto para ejecución rápida y catálogo. + """ + return { + "example_period": 20, + "example_threshold": 0.0, + } + + @classmethod + def parameters_schema(cls) -> Dict[str, Dict[str, Any]]: + """ + Schema de parámetros para UI, validación y futura optimización. + + Formato compatible con el estilo actual de tus estrategias. + """ + return { + "example_period": { + "type": "int", + "min": 2, + "max": 200, + "default": 20, + "step": 1, + "description": "Periodo de ejemplo para indicador o ventana.", + }, + "example_threshold": { + "type": "float", + "min": -10.0, + "max": 10.0, + "default": 0.0, + "step": 0.1, + "description": "Umbral de ejemplo para disparar señales.", + }, + } + + @classmethod + def strategy_metadata(cls) -> Dict[str, Any]: + """ + Metadatos estándar para catálogo, reporting y clasificación por familia. + """ + return { + "strategy_id": cls.strategy_id, + "name": cls.display_name, + "family": cls.strategy_family, + "direction": "long_short", + "description": cls.description, + } + + @classmethod + def strategy_definition(cls) -> Dict[str, Any]: + """ + Definición compacta y estandarizada de la estrategia. + Útil para catálogo enriquecido y futuras capas del pipeline. + """ + return { + "meta": cls.strategy_metadata(), + "defaults": cls.default_parameters(), + "parameters_schema": cls.parameters_schema(), + } + + def init_indicators(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Calcula y añade al DataFrame las columnas auxiliares necesarias. + + Convención recomendada: + - no modificar el df original fuera de una copia + - crear columnas con nombres claros + - evitar side effects + """ + out = df.copy() + + # Ejemplo simple: + out["example_ma"] = out["close"].rolling(self.example_period).mean() + + return out + + def generate_signal(self, row: pd.Series) -> Signal: + """ + Devuelve la señal de trading para una fila. + + Convención típica: + - 1 => long + - -1 => short + - 0 => flat / no signal + + Sustituir esta lógica por la real de la estrategia. + """ + example_ma = row.get("example_ma") + + if pd.isna(example_ma): + return 0 + + close = row.get("close") + if close is None or pd.isna(close): + return 0 + + if close > (example_ma + self.example_threshold): + return 1 + + if close < (example_ma - self.example_threshold): + return -1 + + return 0 \ No newline at end of file diff --git a/src/strategies/trend_filtered.py b/src/strategies/trend_filtered.py deleted file mode 100644 index c81cfa7..0000000 --- a/src/strategies/trend_filtered.py +++ /dev/null @@ -1,104 +0,0 @@ -# src/strategies/trend_filtered.py - -import pandas as pd -import numpy as np - -from src.strategies.base import Strategy -from src.core.strategy import Signal - - -class TrendFilteredMACrossover(Strategy): - """ - Estrategia de cruce de medias con filtro de tendencia. - - Señales: - - BUY: - * Cruce alcista de medias - * Precio por encima de MA lenta - * ADX >= threshold - - SELL: - * Cruce bajista de medias - - HOLD: - * En cualquier otro caso - - Objetivo: - - Evitar whipsaws en mercado lateral - - Operar solo cuando hay estructura de tendencia - - Parámetros por defecto: - fast_period=20 - slow_period=50 - ma_type='ema' - adx_period=14 - adx_threshold=20 - """ - - def __init__( - self, - fast_period: int = 20, - slow_period: int = 50, - ma_type: str = "ema", - adx_period: int = 14, - adx_threshold: float = 20.0, - ): - params = { - "fast_period": fast_period, - "slow_period": slow_period, - "ma_type": ma_type, - "adx_period": adx_period, - "adx_threshold": adx_threshold, - } - - super().__init__(name="TrendFilteredMACrossover", params=params) - - self.fast_period = fast_period - self.slow_period = slow_period - self.ma_type = ma_type - self.adx_period = adx_period - self.adx_threshold = adx_threshold - - # -------------------------------------------------- - - def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: - # Medias móviles - if self.ma_type == "ema": - data["ma_fast"] = data["close"].ewm( - span=self.fast_period, adjust=False - ).mean() - data["ma_slow"] = data["close"].ewm( - span=self.slow_period, adjust=False - ).mean() - else: - data["ma_fast"] = data["close"].rolling(self.fast_period).mean() - data["ma_slow"] = data["close"].rolling(self.slow_period).mean() - - return data - - # -------------------------------------------------- - - def generate_signal(self, idx: int) -> Signal: - if self.data is None or idx < 1: - return Signal.HOLD - - required = {"ma_fast", "ma_slow", "adx", "close"} - if not required.issubset(self.data.columns): - return Signal.HOLD - - row = self.data.iloc[idx] - prev = self.data.iloc[idx - 1] - - if pd.isna(row.adx): - return Signal.HOLD - - cross_up = prev.ma_fast <= prev.ma_slow and row.ma_fast > row.ma_slow - cross_down = prev.ma_fast >= prev.ma_slow and row.ma_fast < row.ma_slow - - trend_ok = row.close > row.ma_slow and row.adx >= self.adx_threshold - - if cross_up and trend_ok: - return Signal.BUY - - if cross_down: - return Signal.SELL - - return Signal.HOLD diff --git a/src/web/api/v2/schemas/calibration_strategies.py b/src/web/api/v2/schemas/calibration_strategies.py index f0a43fd..3291da0 100644 --- a/src/web/api/v2/schemas/calibration_strategies.py +++ b/src/web/api/v2/schemas/calibration_strategies.py @@ -95,16 +95,22 @@ class PromotionConfigSchema(BaseModel): min_avg_trades_per_window: float = 3 promote_score_threshold: float = 70 review_score_threshold: float = 55 + max_strategy_correlation: float = Field(0.85, ge=0.0, le=1.0) + class PromotionResultSchema(BaseModel): strategy_id: str score: float - status: Literal["promote", "review", "reject"] + status: Literal["promote", "review", "reject", "review_diversity"] rank: int + diversity_blocked_by: Optional[str] = None + diversity_correlation: Optional[float] = None + class CalibrationStrategiesPromoteRequest(BaseModel): strategies: List[Dict[str, Any]] promotion: PromotionConfigSchema + class CalibrationStrategiesPromoteResponse(BaseModel): results: List[PromotionResultSchema] \ No newline at end of file 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 3829153..842600d 100644 --- a/src/web/ui/v2/static/js/pages/calibration_strategies.js +++ b/src/web/ui/v2/static/js/pages/calibration_strategies.js @@ -400,22 +400,26 @@ function validateParameterInputs() { function updateCombinationCounter() { - let hasAnyStrategy = false; + let globalTotal = 0; strategySlots.forEach((slot, index) => { - if (!slot.strategy_id) return; - - hasAnyStrategy = true; - const perStrategyEl = document.getElementById(`strategy_combo_${index}`); + + if (!slot.strategy_id) { + if (perStrategyEl) { + perStrategyEl.textContent = "0"; + } + return; + } + + globalTotal += 1; + if (perStrategyEl) { perStrategyEl.textContent = "1"; } }); - const globalTotal = hasAnyStrategy ? 1 : 0; - const globalEl = document.getElementById("combination_counter"); if (globalEl) globalEl.textContent = globalTotal; @@ -445,38 +449,96 @@ function applyCombinationWarnings(total) { } -function updateTimeEstimate(totalComb) { +function updateTimeEstimate(globalTotal) { - const trainDays = parseInt( - document.getElementById("wf_train_days")?.value || 0 - ); + const el = + document.getElementById("wf_time_estimate") || + document.getElementById("time_estimate"); + if (!el) return; - 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`; + if (!globalTotal || globalTotal <= 0) { + el.textContent = "0s"; + return; } - const el = document.getElementById("wf_time_estimate"); - if (el) el.textContent = label; + const trainDays = num("wf_train_days") || 180; + const testDays = num("wf_test_days") || 30; + const stepDays = num("wf_step_days") || 30; + + const dateFrom = str("date_from"); + const dateTo = str("date_to"); + + let totalDays = 0; + if (dateFrom && dateTo) { + const fromTs = new Date(dateFrom).getTime(); + const toTs = new Date(dateTo).getTime(); + + if (Number.isFinite(fromTs) && Number.isFinite(toTs) && toTs > fromTs) { + totalDays = Math.max(1, Math.ceil((toTs - fromTs) / 86400000)); + } + } + + let estimatedWindows = 1; + const minSpan = trainDays + testDays; + + if (totalDays > minSpan && stepDays > 0) { + estimatedWindows = Math.max( + 1, + Math.floor((totalDays - minSpan) / stepDays) + 1 + ); + } + + const barsPerDayMap = { + "1m": 1440, + "3m": 480, + "5m": 288, + "15m": 96, + "30m": 48, + "1h": 24, + "2h": 12, + "4h": 6, + "6h": 4, + "8h": 3, + "12h": 2, + "1d": 1 + }; + + const timeframe = str("timeframe") || "1h"; + const barsPerDay = barsPerDayMap[timeframe] || 24; + + const estimatedBars = Math.max(1, totalDays * barsPerDay); + + // Heurística V1: + // - overhead fijo de arranque + // - coste por estrategia + // - coste por ventana + // - pequeño coste por volumen de datos + const baseSeconds = 1.5; + const perStrategySeconds = 0.9 * globalTotal; + const perWindowSeconds = 0.35 * estimatedWindows * globalTotal; + const dataSeconds = estimatedBars * globalTotal * 0.00003; + + const totalSeconds = Math.max( + 1, + Math.round(baseSeconds + perStrategySeconds + perWindowSeconds + dataSeconds) + ); + + if (totalSeconds < 60) { + el.textContent = `${totalSeconds}s`; + return; + } + + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes < 60) { + el.textContent = seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + return; + } + + const hours = Math.floor(minutes / 60); + const remMinutes = minutes % 60; + el.textContent = remMinutes > 0 ? `${hours}h ${remMinutes}m` : `${hours}h`; } @@ -1746,6 +1808,20 @@ async function init() { .addEventListener("change", updateStopUI); wireButtons(); + [ + "date_from", + "date_to", + "timeframe", + "wf_train_days", + "wf_test_days", + "wf_step_days" + ].forEach(id => { + const el = document.getElementById(id); + if (el) { + el.addEventListener("change", updateCombinationCounter); + el.addEventListener("input", updateCombinationCounter); + } + }); wirePromotionUI(); document.getElementById("plot_strategy_select").addEventListener("change", function() { @@ -2012,7 +2088,8 @@ async function runPromotion() { strategies: window.lastStrategiesResult, promotion: { promote_score_threshold: 70, - review_score_threshold: 55 + review_score_threshold: 55, + max_strategy_correlation: 0.85 } }; @@ -2046,11 +2123,18 @@ function renderPromotionResults(results) { const tr = document.createElement("tr"); + const blockedBy = r.diversity_blocked_by ?? "—"; + const corr = (r.diversity_correlation === null || r.diversity_correlation === undefined) + ? "—" + : Number(r.diversity_correlation).toFixed(4); + tr.innerHTML = ` ${r.rank} ${r.strategy_id} ${r.score} ${r.status} + ${blockedBy} + ${corr} `; table.appendChild(tr); @@ -2058,10 +2142,11 @@ function renderPromotionResults(results) { const promoted = results.filter(r => r.status === "promote").length; const review = results.filter(r => r.status === "review").length; + const reviewDiversity = results.filter(r => r.status === "review_diversity").length; const reject = results.filter(r => r.status === "reject").length; document.getElementById("promotionSummary").innerHTML = - `Promoted: ${promoted} | Review: ${review} | Rejected: ${reject}`; + `Promoted: ${promoted} | Review: ${review} | Review diversity: ${reviewDiversity} | Rejected: ${reject}`; } 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 1d2fefb..d6f8a05 100644 --- a/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html +++ b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html @@ -313,6 +313,8 @@ Strategy Score Status + Blocked by + Corr