need commit to update repository

This commit is contained in:
DaM
2026-03-09 07:59:49 +01:00
parent ca36383bb3
commit f3de09067e
27 changed files with 1134 additions and 459 deletions

View File

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

View File

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

View File

@@ -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": [],
},
}

View File

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

View File

@@ -1,23 +1,30 @@
# src/calibration/strategy_promotion.py
from typing import List, Dict, Any
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

View File

@@ -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
Calcula Bollinger Bands.
Args:
data: Serie de precios
period: Periodo de la media móvil
std_dev: Número de desviaciones estándar
Devuelve:
- mid_band: media móvil simple
- upper_band: mid + std_dev * rolling_std
- lower_band: mid - std_dev * rolling_std
Returns:
Tupla (Upper Band, Middle Band, Lower Band)
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()
if period <= 0:
raise ValueError("period must be > 0")
if std_dev <= 0:
raise ValueError("std_dev must be > 0")
upper = middle + (std * std_dev)
lower = middle - (std * std_dev)
mid_band = data.rolling(window=period).mean()
rolling_std = data.rolling(window=period).std(ddof=0)
return upper, middle, lower
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:
"""
@@ -196,3 +206,33 @@ def calculate_atr(high: pd.Series, low: pd.Series, close: pd.Series, period: int
atr = tr.rolling(window=period).mean()
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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
from itertools import product
from src.strategies.moving_average import MovingAverageCrossover
from src.strategies.ma_crossover import MovingAverageCrossover
class MACrossoverOptimization:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = `
<td>${r.rank}</td>
<td>${r.strategy_id}</td>
<td>${r.score}</td>
<td>${r.status}</td>
<td>${blockedBy}</td>
<td>${corr}</td>
`;
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}`;
}

View File

@@ -313,6 +313,8 @@
<th>Strategy</th>
<th>Score</th>
<th>Status</th>
<th>Blocked by</th>
<th>Corr</th>
</tr>
</thead>
<tbody></tbody>