need commit to update repository
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
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))
|
||||
@@ -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',
|
||||
|
||||
136
src/strategies/bollinger_reversion.py
Normal file
136
src/strategies/bollinger_reversion.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
130
src/strategies/donchian_breakout.py
Normal file
130
src/strategies/donchian_breakout.py
Normal 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
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
from itertools import product
|
||||
from src.strategies.moving_average import MovingAverageCrossover
|
||||
from src.strategies.ma_crossover import MovingAverageCrossover
|
||||
|
||||
class MACrossoverOptimization:
|
||||
|
||||
|
||||
@@ -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)
|
||||
169
src/strategies/regime_filtered_trend.py
Normal file
169
src/strategies/regime_filtered_trend.py
Normal 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
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
138
src/strategies/roc_momentum.py
Normal file
138
src/strategies/roc_momentum.py
Normal 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
|
||||
@@ -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 {
|
||||
154
src/strategies/strategy_template.py
Normal file
154
src/strategies/strategy_template.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -313,6 +313,8 @@
|
||||
<th>Strategy</th>
|
||||
<th>Score</th>
|
||||
<th>Status</th>
|
||||
<th>Blocked by</th>
|
||||
<th>Corr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
|
||||
Reference in New Issue
Block a user