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.sizing.percent_risk import PercentRiskSizer
|
||||||
from src.risk.stops.trailing_stop import TrailingStop
|
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.strategies.trend_filtered import TrendFilteredMACrossover
|
||||||
|
|
||||||
from src.portfolio.portfolio_engine import PortfolioEngine
|
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.sizing.percent_risk import PercentRiskSizer
|
||||||
from src.risk.stops.trailing_stop import TrailingStop
|
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.strategies.trend_filtered import TrendFilteredMACrossover
|
||||||
|
|
||||||
from src.portfolio.portfolio_engine import PortfolioEngine
|
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)
|
# Strategy registry (con metadata de parámetros)
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
from src.strategies.moving_average import MovingAverageCrossover
|
from src.strategies.ma_crossover import MovingAverageCrossover
|
||||||
from src.strategies.rsi_strategy import RSIStrategy
|
from src.strategies.rsi_reversion import RSIStrategy
|
||||||
from src.strategies.buy_and_hold import BuyAndHold
|
|
||||||
|
|
||||||
|
|
||||||
STRATEGY_REGISTRY = {
|
STRATEGY_REGISTRY = {
|
||||||
@@ -35,10 +34,6 @@ STRATEGY_REGISTRY = {
|
|||||||
"class": RSIStrategy,
|
"class": RSIStrategy,
|
||||||
"params": ["rsi_period", "overbought", "oversold"],
|
"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)
|
# Strategy registry (con metadata de parámetros)
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
from src.strategies.registry import STRATEGY_REGISTRY
|
from src.strategies.registry import STRATEGY_REGISTRY
|
||||||
from src.strategies.moving_average import MovingAverageCrossover
|
from src.strategies.ma_crossover import MovingAverageCrossover
|
||||||
from src.strategies.rsi_strategy import RSIStrategy
|
from src.strategies.rsi_reversion import RSIStrategy
|
||||||
from src.strategies.buy_and_hold import BuyAndHold
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -278,10 +277,19 @@ def inspect_strategies_config(
|
|||||||
|
|
||||||
# Regime analysis is market-level (shared by all strategies for the same WF config)
|
# Regime analysis is market-level (shared by all strategies for the same WF config)
|
||||||
regime_cfg = TrendScoreConfig()
|
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(
|
wf_probe = WalkForwardValidator(
|
||||||
strategy_class=BuyAndHold,
|
strategy_class=probe_class,
|
||||||
param_grid=None,
|
param_grid=None,
|
||||||
fixed_params={},
|
fixed_params=probe_params,
|
||||||
data=df,
|
data=df,
|
||||||
train_window=train_td,
|
train_window=train_td,
|
||||||
test_window=test_td,
|
test_window=test_td,
|
||||||
@@ -293,6 +301,7 @@ def inspect_strategies_config(
|
|||||||
stop_loss=stop_loss,
|
stop_loss=stop_loss,
|
||||||
verbose=False,
|
verbose=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
wf_windows = wf_probe._generate_windows()
|
wf_windows = wf_probe._generate_windows()
|
||||||
regime_bundle = compute_regimes_for_windows(df, wf_windows, config=regime_cfg)
|
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"]}
|
regime_by_window = {int(r["window"]): r for r in regime_bundle["by_window"]}
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
#src/calibration/strategy_promotion.py
|
# 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):
|
def _clamp(v, lo, hi):
|
||||||
return max(lo, min(hi, v))
|
return max(lo, min(hi, v))
|
||||||
|
|
||||||
|
|
||||||
def _score_return(oos_return):
|
def _score_return(oos_return):
|
||||||
return _clamp((oos_return / 50.0) * 30.0, 0, 30)
|
return _clamp((oos_return / 50.0) * 30.0, 0, 30)
|
||||||
|
|
||||||
|
|
||||||
def _score_stability(positive_rate, std_return):
|
def _score_stability(positive_rate, std_return):
|
||||||
score = positive_rate * 15
|
score = positive_rate * 15
|
||||||
score += _clamp(10 - std_return, 0, 10)
|
score += _clamp(10 - std_return, 0, 10)
|
||||||
return _clamp(score, 0, 25)
|
return _clamp(score, 0, 25)
|
||||||
|
|
||||||
|
|
||||||
def _score_risk(worst_dd):
|
def _score_risk(worst_dd):
|
||||||
return _clamp((1 + worst_dd / 20.0) * 20.0, 0, 20)
|
return _clamp((1 + worst_dd / 20.0) * 20.0, 0, 20)
|
||||||
|
|
||||||
|
|
||||||
def _score_trades(avg_trades):
|
def _score_trades(avg_trades):
|
||||||
return _clamp(avg_trades / 10.0 * 10.0, 0, 10)
|
return _clamp(avg_trades / 10.0 * 10.0, 0, 10)
|
||||||
|
|
||||||
|
|
||||||
def _score_regime(regime_detail: Dict[str, Any]):
|
def _score_regime(regime_detail: Dict[str, Any]):
|
||||||
if not regime_detail:
|
if not regime_detail:
|
||||||
return 0
|
return 0
|
||||||
@@ -25,17 +32,66 @@ def _score_regime(regime_detail: Dict[str, Any]):
|
|||||||
total = len(regime_detail)
|
total = len(regime_detail)
|
||||||
return (positives / total) * 15 if total else 0
|
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)
|
def _extract_window_returns(strategy: Dict[str, Any]) -> List[float]:
|
||||||
worst_dd = strategy.get("oos_max_dd_worst_pct", -100)
|
windows = strategy.get("windows", []) or []
|
||||||
positive_rate = stability.get("positive_window_rate", 0)
|
out = []
|
||||||
std_return = stability.get("std_return_pct", 0)
|
|
||||||
avg_trades = trades.get("avg_trades_per_window", 0)
|
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 = 0
|
||||||
score += _score_return(oos_return)
|
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"),
|
"strategy_id": strategy.get("strategy_id"),
|
||||||
"score": round(score, 2),
|
"score": round(score, 2),
|
||||||
"status": status,
|
"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]):
|
def rank_strategies(strategies: List[Dict[str, Any]], config: Dict[str, Any]):
|
||||||
evaluated = [evaluate_strategy(s, config) for s in strategies]
|
evaluated = [evaluate_strategy(s, config) for s in strategies]
|
||||||
evaluated.sort(key=lambda x: x["score"], reverse=True)
|
evaluated.sort(key=lambda x: x["score"], reverse=True)
|
||||||
|
|
||||||
for i, e in enumerate(evaluated):
|
for i, e in enumerate(evaluated):
|
||||||
e["rank"] = i + 1
|
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
|
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
|
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:
|
Devuelve:
|
||||||
data: Serie de precios
|
- mid_band: media móvil simple
|
||||||
period: Periodo de la media móvil
|
- upper_band: mid + std_dev * rolling_std
|
||||||
std_dev: Número de desviaciones estándar
|
- lower_band: mid - std_dev * rolling_std
|
||||||
|
|
||||||
Returns:
|
Notas:
|
||||||
Tupla (Upper Band, Middle Band, Lower Band)
|
- 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)
|
if period <= 0:
|
||||||
std = data.rolling(window=period).std()
|
raise ValueError("period must be > 0")
|
||||||
|
if std_dev <= 0:
|
||||||
|
raise ValueError("std_dev must be > 0")
|
||||||
|
|
||||||
upper = middle + (std * std_dev)
|
mid_band = data.rolling(window=period).mean()
|
||||||
lower = middle - (std * std_dev)
|
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:
|
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()
|
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
|
Colección de estrategias de trading
|
||||||
"""
|
"""
|
||||||
from .moving_average import MovingAverageCrossover
|
from .ma_crossover import MovingAverageCrossover
|
||||||
from .buy_and_hold import BuyAndHold
|
from .rsi_reversion import RSIStrategy
|
||||||
from .rsi_strategy import RSIStrategy
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'MovingAverageCrossover',
|
'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
|
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)
|
- BUY: Cruce alcista de medias + (ADX >= threshold si está activado)
|
||||||
- SELL: Cruce bajista de medias
|
- SELL: Cruce bajista de medias
|
||||||
- HOLD: En cualquier otro caso
|
- HOLD: En cualquier otro caso
|
||||||
@@ -34,6 +28,9 @@ class MovingAverageCrossover(Strategy):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
strategy_id = "moving_average"
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -62,6 +59,38 @@ class MovingAverageCrossover(Strategy):
|
|||||||
if self.ma_type not in ['sma', 'ema']:
|
if self.ma_type not in ['sma', 'ema']:
|
||||||
raise ValueError("ma_type debe ser 'sma' o '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
|
@classmethod
|
||||||
def parameters_schema(cls) -> dict:
|
def parameters_schema(cls) -> dict:
|
||||||
return {
|
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 itertools import product
|
||||||
from src.strategies.moving_average import MovingAverageCrossover
|
from src.strategies.ma_crossover import MovingAverageCrossover
|
||||||
|
|
||||||
class MACrossoverOptimization:
|
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
|
# src/strategies/registry.py
|
||||||
from .moving_average import MovingAverageCrossover
|
from .ma_crossover import MovingAverageCrossover
|
||||||
from .rsi_strategy import RSIStrategy
|
from .rsi_reversion import RSIStrategy
|
||||||
from .buy_and_hold import BuyAndHold
|
from .bollinger_reversion import BollingerReversion
|
||||||
|
from .donchian_breakout import DonchianBreakout
|
||||||
|
from .roc_momentum import ROCMomentum
|
||||||
|
from .regime_filtered_trend import RegimeFilteredTrend
|
||||||
|
|
||||||
|
|
||||||
ALL_STRATEGIES = [
|
ALL_STRATEGIES = [
|
||||||
MovingAverageCrossover,
|
MovingAverageCrossover,
|
||||||
RSIStrategy,
|
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_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):
|
def __init__(self, rsi_period: int = 14, oversold: float = 30, overbought: float = 70):
|
||||||
|
|
||||||
@@ -36,6 +39,34 @@ class RSIStrategy(Strategy):
|
|||||||
self.oversold = oversold
|
self.oversold = oversold
|
||||||
self.overbought = overbought
|
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
|
@classmethod
|
||||||
def parameters_schema(cls) -> dict:
|
def parameters_schema(cls) -> dict:
|
||||||
return {
|
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
|
min_avg_trades_per_window: float = 3
|
||||||
promote_score_threshold: float = 70
|
promote_score_threshold: float = 70
|
||||||
review_score_threshold: float = 55
|
review_score_threshold: float = 55
|
||||||
|
max_strategy_correlation: float = Field(0.85, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
class PromotionResultSchema(BaseModel):
|
class PromotionResultSchema(BaseModel):
|
||||||
strategy_id: str
|
strategy_id: str
|
||||||
score: float
|
score: float
|
||||||
status: Literal["promote", "review", "reject"]
|
status: Literal["promote", "review", "reject", "review_diversity"]
|
||||||
rank: int
|
rank: int
|
||||||
|
diversity_blocked_by: Optional[str] = None
|
||||||
|
diversity_correlation: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class CalibrationStrategiesPromoteRequest(BaseModel):
|
class CalibrationStrategiesPromoteRequest(BaseModel):
|
||||||
strategies: List[Dict[str, Any]]
|
strategies: List[Dict[str, Any]]
|
||||||
promotion: PromotionConfigSchema
|
promotion: PromotionConfigSchema
|
||||||
|
|
||||||
|
|
||||||
class CalibrationStrategiesPromoteResponse(BaseModel):
|
class CalibrationStrategiesPromoteResponse(BaseModel):
|
||||||
results: List[PromotionResultSchema]
|
results: List[PromotionResultSchema]
|
||||||
@@ -400,22 +400,26 @@ function validateParameterInputs() {
|
|||||||
|
|
||||||
function updateCombinationCounter() {
|
function updateCombinationCounter() {
|
||||||
|
|
||||||
let hasAnyStrategy = false;
|
let globalTotal = 0;
|
||||||
|
|
||||||
strategySlots.forEach((slot, index) => {
|
strategySlots.forEach((slot, index) => {
|
||||||
|
|
||||||
if (!slot.strategy_id) return;
|
|
||||||
|
|
||||||
hasAnyStrategy = true;
|
|
||||||
|
|
||||||
const perStrategyEl = document.getElementById(`strategy_combo_${index}`);
|
const perStrategyEl = document.getElementById(`strategy_combo_${index}`);
|
||||||
|
|
||||||
|
if (!slot.strategy_id) {
|
||||||
|
if (perStrategyEl) {
|
||||||
|
perStrategyEl.textContent = "0";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalTotal += 1;
|
||||||
|
|
||||||
if (perStrategyEl) {
|
if (perStrategyEl) {
|
||||||
perStrategyEl.textContent = "1";
|
perStrategyEl.textContent = "1";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalTotal = hasAnyStrategy ? 1 : 0;
|
|
||||||
|
|
||||||
const globalEl = document.getElementById("combination_counter");
|
const globalEl = document.getElementById("combination_counter");
|
||||||
if (globalEl) globalEl.textContent = globalTotal;
|
if (globalEl) globalEl.textContent = globalTotal;
|
||||||
|
|
||||||
@@ -445,38 +449,96 @@ function applyCombinationWarnings(total) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function updateTimeEstimate(totalComb) {
|
function updateTimeEstimate(globalTotal) {
|
||||||
|
|
||||||
const trainDays = parseInt(
|
const el =
|
||||||
document.getElementById("wf_train_days")?.value || 0
|
document.getElementById("wf_time_estimate") ||
|
||||||
);
|
document.getElementById("time_estimate");
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
const testDays = parseInt(
|
if (!globalTotal || globalTotal <= 0) {
|
||||||
document.getElementById("wf_test_days")?.value || 0
|
el.textContent = "0s";
|
||||||
);
|
return;
|
||||||
|
|
||||||
const approxWindows = Math.max(
|
|
||||||
Math.floor(365 / testDays),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
const operations = totalComb * approxWindows;
|
|
||||||
|
|
||||||
// 0.003s por combinación (estimación conservadora)
|
|
||||||
const seconds = operations * 0.003;
|
|
||||||
|
|
||||||
let label;
|
|
||||||
|
|
||||||
if (seconds < 60) {
|
|
||||||
label = `~ ${seconds.toFixed(1)} sec`;
|
|
||||||
} else if (seconds < 3600) {
|
|
||||||
label = `~ ${(seconds / 60).toFixed(1)} min`;
|
|
||||||
} else {
|
|
||||||
label = `~ ${(seconds / 3600).toFixed(1)} h`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = document.getElementById("wf_time_estimate");
|
const trainDays = num("wf_train_days") || 180;
|
||||||
if (el) el.textContent = label;
|
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);
|
.addEventListener("change", updateStopUI);
|
||||||
|
|
||||||
wireButtons();
|
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();
|
wirePromotionUI();
|
||||||
|
|
||||||
document.getElementById("plot_strategy_select").addEventListener("change", function() {
|
document.getElementById("plot_strategy_select").addEventListener("change", function() {
|
||||||
@@ -2012,7 +2088,8 @@ async function runPromotion() {
|
|||||||
strategies: window.lastStrategiesResult,
|
strategies: window.lastStrategiesResult,
|
||||||
promotion: {
|
promotion: {
|
||||||
promote_score_threshold: 70,
|
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 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 = `
|
tr.innerHTML = `
|
||||||
<td>${r.rank}</td>
|
<td>${r.rank}</td>
|
||||||
<td>${r.strategy_id}</td>
|
<td>${r.strategy_id}</td>
|
||||||
<td>${r.score}</td>
|
<td>${r.score}</td>
|
||||||
<td>${r.status}</td>
|
<td>${r.status}</td>
|
||||||
|
<td>${blockedBy}</td>
|
||||||
|
<td>${corr}</td>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
table.appendChild(tr);
|
table.appendChild(tr);
|
||||||
@@ -2058,10 +2142,11 @@ function renderPromotionResults(results) {
|
|||||||
|
|
||||||
const promoted = results.filter(r => r.status === "promote").length;
|
const promoted = results.filter(r => r.status === "promote").length;
|
||||||
const review = results.filter(r => r.status === "review").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;
|
const reject = results.filter(r => r.status === "reject").length;
|
||||||
|
|
||||||
document.getElementById("promotionSummary").innerHTML =
|
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>Strategy</th>
|
||||||
<th>Score</th>
|
<th>Score</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Blocked by</th>
|
||||||
|
<th>Corr</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user