mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-19 04:58:07 +00:00
Phase 1: Event-driven backtester, 5 strategies, and baseline results
- Built event-driven backtesting engine with spread/slippage modeling, 3-TP partial closes, trailing stops, and rich trade logging (20+ features) - Implemented 5 strategy signal generators (MA Breakout, VWAP Reversal, Key Level Breakout, EMA Ribbon Scalp, Momentum Exhaustion) - Full indicator library (EMA, SMA, RSI, ATR, MACD, ADX, Stochastic, Session VWAP bands, swing points, key levels, RSI divergence) - Data pipeline: Dukascopy download, validation, 70/30 train/test split - Baseline results: all 5 strategies generate 200+ trades on training data (Jan 2021 - Aug 2023), best profit factors 0.82-0.96 on select pairs - Trade logs and reports saved for Phase 3 ML feature engineering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5d7f6c60a9
commit
dce54845c2
@@ -0,0 +1,31 @@
|
||||
from .s1_ma_breakout import S1_MA_Breakout
|
||||
from .s2_vwap_reversal import S2_VWAP_Reversal
|
||||
from .s3_key_level_breakout import S3_KeyLevel_Breakout
|
||||
from .s4_ema_ribbon import S4_EMA_Ribbon
|
||||
from .s5_momentum_exhaustion import S5_Momentum_Exhaustion
|
||||
|
||||
STRATEGIES = {
|
||||
1: S1_MA_Breakout,
|
||||
2: S2_VWAP_Reversal,
|
||||
3: S3_KeyLevel_Breakout,
|
||||
4: S4_EMA_Ribbon,
|
||||
5: S5_Momentum_Exhaustion,
|
||||
}
|
||||
|
||||
# Which pairs each strategy trades
|
||||
STRATEGY_PAIRS = {
|
||||
1: ["GBP_AUD", "EUR_AUD", "EUR_CAD", "EUR_NZD"],
|
||||
2: ["GBP_USD", "EUR_USD", "GBP_JPY", "USD_JPY"],
|
||||
3: ["GBP_JPY", "USD_JPY", "GBP_USD", "EUR_GBP"],
|
||||
4: ["GBP_AUD", "EUR_AUD", "EUR_GBP"],
|
||||
5: ["GBP_AUD", "EUR_AUD", "EUR_GBP", "GBP_CAD", "EUR_CAD"],
|
||||
}
|
||||
|
||||
# Primary and filter timeframes
|
||||
STRATEGY_TIMEFRAMES = {
|
||||
1: {"primary": "M15", "filter": "H1"},
|
||||
2: {"primary": "M15", "filter": None},
|
||||
3: {"primary": "H1", "filter": None}, # Uses internal key level detection
|
||||
4: {"primary": "M15", "filter": "H1"},
|
||||
5: {"primary": "M15", "filter": "H1"},
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Base strategy interface."""
|
||||
from typing import Optional
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class BaseStrategy:
|
||||
strategy_id: int = 0
|
||||
name: str = "BaseStrategy"
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
"""
|
||||
Check for a trade signal at the current candle.
|
||||
|
||||
Args:
|
||||
data: Full precomputed dataframe (must only access [:idx+1]).
|
||||
idx: Current bar index into data.
|
||||
current: The current candle (data.iloc[idx]).
|
||||
htf_row: Most recent fully-closed higher timeframe candle (or None).
|
||||
|
||||
Returns:
|
||||
dict with keys: direction, sl, tp1, tp2, tp3, confluence,
|
||||
tp_splits, trail_atr_mult, max_bars
|
||||
or None if no signal.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Strategy 1: MA Breakout-Retest (Option 3 - no trendlines).
|
||||
|
||||
Uses MA structure + key level breaks + candle confirmation.
|
||||
Entry TF: M15, Filter TF: H1 (200 SMA directional filter).
|
||||
|
||||
Entry conditions (LONG):
|
||||
- EMA 50 > EMA 100 > EMA 200 (trend alignment)
|
||||
- Price pulls back to EMA 50 zone (within 1.0x ATR)
|
||||
- Bullish confirmation candle (close > open, close > prev close, body > 30% range)
|
||||
- H1 close > H1 200 SMA (HTF filter)
|
||||
- Session filter: London/NY hours only (08:00-17:00 UTC)
|
||||
- Confluence >= 2
|
||||
|
||||
Boosters (confluence 0-5):
|
||||
- Volume above 20-period average
|
||||
- RSI between 40-60 (not overextended)
|
||||
- MACD histogram positive and rising
|
||||
- ADX > 20 (trending)
|
||||
- Price above session VWAP
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S1_MA_Breakout(BaseStrategy):
|
||||
strategy_id = 1
|
||||
name = "S1_MA_Breakout_Retest"
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 50:
|
||||
return None
|
||||
|
||||
# Session filter: only London + NY (08:00-17:00 UTC)
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 17:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
ema_50 = current.get("ema_50", np.nan)
|
||||
ema_100 = current.get("ema_100", np.nan)
|
||||
ema_200 = current.get("ema_200", np.nan)
|
||||
if any(np.isnan(v) for v in [ema_50, ema_100, ema_200]):
|
||||
return None
|
||||
|
||||
close = current["close"]
|
||||
open_p = current["open"]
|
||||
prev = data.iloc[idx - 1]
|
||||
prev_close = prev["close"]
|
||||
|
||||
# Candle body filter: body must be > 30% of range (no dojis)
|
||||
body = abs(close - open_p)
|
||||
full_range = current["high"] - current["low"]
|
||||
if full_range <= 0 or body / full_range < 0.3:
|
||||
return None
|
||||
|
||||
# LONG setup
|
||||
if ema_50 > ema_100 > ema_200:
|
||||
# HTF filter
|
||||
if htf_row is not None:
|
||||
htf_sma200 = htf_row.get("sma_200", np.nan)
|
||||
if not np.isnan(htf_sma200) and htf_row.get("close", 0) <= htf_sma200:
|
||||
return None
|
||||
|
||||
# Pullback to EMA 50 zone (within 1.0x ATR - tightened from 1.5x)
|
||||
dist_to_ema50 = close - ema_50
|
||||
if dist_to_ema50 < 0 or dist_to_ema50 > 1.0 * atr_val:
|
||||
return None
|
||||
|
||||
# Bullish confirmation candle
|
||||
if not (close > open_p and close > prev_close):
|
||||
return None
|
||||
|
||||
# Not too far from EMAs (avoid chasing)
|
||||
if close - ema_200 > 5 * atr_val:
|
||||
return None
|
||||
|
||||
confluence = self._calc_confluence(data, idx, current, "LONG")
|
||||
|
||||
# Require minimum confluence of 2
|
||||
if confluence < 2:
|
||||
return None
|
||||
|
||||
sl = current["low"] - 0.5 * atr_val
|
||||
tp1 = close + 1.5 * atr_val
|
||||
tp2 = close + 2.5 * atr_val
|
||||
tp3 = close + 4.0 * atr_val
|
||||
|
||||
return {
|
||||
"direction": "LONG",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.50, 0.30, 0.20),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": 200,
|
||||
}
|
||||
|
||||
# SHORT setup
|
||||
if ema_50 < ema_100 < ema_200:
|
||||
if htf_row is not None:
|
||||
htf_sma200 = htf_row.get("sma_200", np.nan)
|
||||
if not np.isnan(htf_sma200) and htf_row.get("close", 0) >= htf_sma200:
|
||||
return None
|
||||
|
||||
dist_to_ema50 = ema_50 - close
|
||||
if dist_to_ema50 < 0 or dist_to_ema50 > 1.0 * atr_val:
|
||||
return None
|
||||
|
||||
if not (close < open_p and close < prev_close):
|
||||
return None
|
||||
|
||||
if ema_200 - close > 5 * atr_val:
|
||||
return None
|
||||
|
||||
confluence = self._calc_confluence(data, idx, current, "SHORT")
|
||||
|
||||
if confluence < 2:
|
||||
return None
|
||||
|
||||
sl = current["high"] + 0.5 * atr_val
|
||||
tp1 = close - 1.5 * atr_val
|
||||
tp2 = close - 2.5 * atr_val
|
||||
tp3 = close - 4.0 * atr_val
|
||||
|
||||
return {
|
||||
"direction": "SHORT",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.50, 0.30, 0.20),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": 200,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _calc_confluence(self, data, idx, current, direction):
|
||||
confluence = 0
|
||||
|
||||
# Volume above average
|
||||
if "volume" in current.index:
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and current["volume"] > vol_avg:
|
||||
confluence += 1
|
||||
|
||||
# RSI between 40-60
|
||||
rsi = current.get("rsi_14", 50)
|
||||
if 40 <= rsi <= 60:
|
||||
confluence += 1
|
||||
|
||||
# MACD histogram confirmation
|
||||
macd_h = current.get("macd_hist", 0)
|
||||
prev_macd_h = data.iloc[idx - 1].get("macd_hist", 0)
|
||||
if direction == "LONG" and macd_h > 0 and macd_h > prev_macd_h:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and macd_h < 0 and macd_h < prev_macd_h:
|
||||
confluence += 1
|
||||
|
||||
# ADX > 20
|
||||
if current.get("adx_14", 0) > 20:
|
||||
confluence += 1
|
||||
|
||||
# VWAP alignment
|
||||
vwap = current.get("session_vwap", 0)
|
||||
if vwap:
|
||||
if direction == "LONG" and current["close"] > vwap:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and current["close"] < vwap:
|
||||
confluence += 1
|
||||
|
||||
return min(confluence, 5)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Strategy 2: Session VWAP Reversal.
|
||||
|
||||
Entry TF: M15, No HTF filter needed.
|
||||
|
||||
Entry conditions (LONG):
|
||||
- Price crosses below VWAP -2 sigma band
|
||||
- RSI < 30 (oversold confirmation)
|
||||
- Session filter: London/NY hours only (08:00-17:00 UTC)
|
||||
- Minimum band width: vwap_std > 0.3 * ATR
|
||||
|
||||
Entry conditions (SHORT):
|
||||
- Price crosses above VWAP +2 sigma band
|
||||
- RSI > 70 (overbought confirmation)
|
||||
|
||||
TP1: Return to VWAP, TP2: Opposite 0.5 sigma band
|
||||
SL: Beyond session high/low OR 1x ATR (whichever tighter)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S2_VWAP_Reversal(BaseStrategy):
|
||||
strategy_id = 2
|
||||
name = "S2_Session_VWAP_Reversal"
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 30:
|
||||
return None
|
||||
|
||||
# Session filter: only London + NY (08:00-17:00 UTC)
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 17:
|
||||
return None
|
||||
|
||||
vwap = current.get("session_vwap", None)
|
||||
upper_2 = current.get("vwap_upper_2", None)
|
||||
lower_2 = current.get("vwap_lower_2", None)
|
||||
|
||||
if vwap is None or upper_2 is None or lower_2 is None:
|
||||
return None
|
||||
if any(np.isnan(v) for v in [vwap, upper_2, lower_2]):
|
||||
return None
|
||||
|
||||
# Skip if bands are too tight (early in session, no deviation yet)
|
||||
vwap_std = current.get("vwap_std", 0)
|
||||
if vwap_std is None or np.isnan(vwap_std) or vwap_std <= 0:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# Minimum band width: std must be meaningful relative to ATR
|
||||
if vwap_std < 0.3 * atr_val:
|
||||
return None
|
||||
|
||||
rsi = current.get("rsi_14", 50)
|
||||
if np.isnan(rsi):
|
||||
return None
|
||||
|
||||
close = current["close"]
|
||||
prev = data.iloc[idx - 1]
|
||||
|
||||
# Session high/low for SL
|
||||
start = max(0, idx - 80)
|
||||
session_high = data["high"].iloc[start:idx + 1].max()
|
||||
session_low = data["low"].iloc[start:idx + 1].min()
|
||||
|
||||
# LONG: price below -2 sigma + RSI < 30 + price just crossed below
|
||||
if close < lower_2 and rsi < 30:
|
||||
prev_lower_2 = prev.get("vwap_lower_2", None)
|
||||
if prev_lower_2 is not None and not np.isnan(prev_lower_2):
|
||||
if prev["close"] >= prev_lower_2:
|
||||
return self._build_long(close, vwap, atr_val, vwap_std,
|
||||
session_low, current)
|
||||
|
||||
# SHORT: price above +2 sigma + RSI > 70 + price just crossed above
|
||||
if close > upper_2 and rsi > 70:
|
||||
prev_upper_2 = prev.get("vwap_upper_2", None)
|
||||
if prev_upper_2 is not None and not np.isnan(prev_upper_2):
|
||||
if prev["close"] <= prev_upper_2:
|
||||
return self._build_short(close, vwap, atr_val, vwap_std,
|
||||
session_high, current)
|
||||
|
||||
return None
|
||||
|
||||
def _build_long(self, close, vwap, atr_val, vwap_std, session_low, current):
|
||||
sl_session = session_low - 0.5 * atr_val
|
||||
sl_atr = close - 1.5 * atr_val # Wider SL for mean reversion
|
||||
sl = max(sl_session, sl_atr)
|
||||
|
||||
tp1 = vwap
|
||||
tp2 = vwap + 0.5 * vwap_std
|
||||
tp3 = vwap + 1.5 * vwap_std
|
||||
|
||||
confluence = 2
|
||||
if current.get("adx_14", 30) < 25:
|
||||
confluence += 1
|
||||
macd_h = current.get("macd_hist", 0)
|
||||
if macd_h > 0:
|
||||
confluence += 1
|
||||
|
||||
return {
|
||||
"direction": "LONG",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.50, 0.35, 0.15),
|
||||
"trail_atr_mult": 1.0,
|
||||
"max_bars": 80,
|
||||
}
|
||||
|
||||
def _build_short(self, close, vwap, atr_val, vwap_std, session_high, current):
|
||||
sl_session = session_high + 0.5 * atr_val
|
||||
sl_atr = close + 1.5 * atr_val # Wider SL for mean reversion
|
||||
sl = min(sl_session, sl_atr)
|
||||
|
||||
tp1 = vwap
|
||||
tp2 = vwap - 0.5 * vwap_std
|
||||
tp3 = vwap - 1.5 * vwap_std
|
||||
|
||||
confluence = 2
|
||||
if current.get("adx_14", 30) < 25:
|
||||
confluence += 1
|
||||
macd_h = current.get("macd_hist", 0)
|
||||
if macd_h < 0:
|
||||
confluence += 1
|
||||
|
||||
return {
|
||||
"direction": "SHORT",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.50, 0.35, 0.15),
|
||||
"trail_atr_mult": 1.0,
|
||||
"max_bars": 80,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Strategy 3: Key Level Momentum Breakout.
|
||||
|
||||
Entry TF: H1. Key levels identified from swing point clusters.
|
||||
|
||||
Entry conditions (LONG):
|
||||
- H1 candle closes above a key level (horizontal S/R with 2+ touches)
|
||||
- MACD histogram same sign as direction
|
||||
- ADX > 15
|
||||
- Candle body > 30% of range (conviction candle)
|
||||
|
||||
SL: Back inside key level + 1x ATR buffer
|
||||
TP1: 1.5x ATR, TP2: 2.5x ATR, TP3: 4x ATR
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
from ..indicators.technical import identify_key_levels
|
||||
|
||||
|
||||
class S3_KeyLevel_Breakout(BaseStrategy):
|
||||
strategy_id = 3
|
||||
name = "S3_Key_Level_Breakout"
|
||||
|
||||
def __init__(self):
|
||||
self._cached_levels = None
|
||||
self._cache_idx = -1
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 100:
|
||||
return None
|
||||
|
||||
# Session filter: London/NY only (08:00-17:00 UTC)
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 17:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# Recalculate key levels every 20 bars using larger lookback
|
||||
if self._cached_levels is None or idx - self._cache_idx >= 20:
|
||||
start = max(0, idx - 1000)
|
||||
window = data.iloc[start:idx] # exclude current bar
|
||||
self._cached_levels = identify_key_levels(
|
||||
window, lookback=5, tolerance_atr_mult=0.75, min_touches=2
|
||||
)
|
||||
self._cache_idx = idx
|
||||
|
||||
if not self._cached_levels:
|
||||
return None
|
||||
|
||||
close = current["close"]
|
||||
prev_close = data.iloc[idx - 1]["close"]
|
||||
|
||||
# Candle body filter
|
||||
body = abs(close - current["open"])
|
||||
full_range = current["high"] - current["low"]
|
||||
if full_range <= 0 or body / full_range < 0.3:
|
||||
return None
|
||||
|
||||
# MACD
|
||||
macd_h = current.get("macd_hist", 0)
|
||||
|
||||
# ADX
|
||||
adx_val = current.get("adx_14", 0)
|
||||
if adx_val < 15:
|
||||
return None
|
||||
|
||||
for level_price, touch_count in self._cached_levels:
|
||||
tolerance = 0.3 * atr_val
|
||||
|
||||
# LONG breakout: close above level, prev close was at or below
|
||||
if close > level_price + tolerance and prev_close <= level_price + tolerance:
|
||||
if macd_h <= 0:
|
||||
continue
|
||||
|
||||
# EMA alignment: 50 > 200 for LONG
|
||||
ema_50 = current.get("ema_50", 0)
|
||||
ema_200 = current.get("ema_200", 0)
|
||||
if ema_50 and ema_200 and ema_50 <= ema_200:
|
||||
continue
|
||||
|
||||
confluence = self._calc_confluence(current, data, idx, "LONG", touch_count)
|
||||
|
||||
sl = level_price - 0.3 * atr_val # Tight SL just inside key level
|
||||
tp1 = close + 1.5 * atr_val
|
||||
tp2 = close + 2.5 * atr_val
|
||||
tp3 = close + 4.0 * atr_val
|
||||
|
||||
return {
|
||||
"direction": "LONG",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.40, 0.40, 0.20),
|
||||
"trail_atr_mult": 2.0,
|
||||
"max_bars": 150,
|
||||
}
|
||||
|
||||
# SHORT breakout: close below level, prev close was at or above
|
||||
if close < level_price - tolerance and prev_close >= level_price - tolerance:
|
||||
if macd_h >= 0:
|
||||
continue
|
||||
|
||||
# EMA alignment: 50 < 200 for SHORT
|
||||
ema_50 = current.get("ema_50", 0)
|
||||
ema_200 = current.get("ema_200", 0)
|
||||
if ema_50 and ema_200 and ema_50 >= ema_200:
|
||||
continue
|
||||
|
||||
confluence = self._calc_confluence(current, data, idx, "SHORT", touch_count)
|
||||
|
||||
sl = level_price + 0.3 * atr_val # Tight SL just inside key level
|
||||
tp1 = close - 1.5 * atr_val
|
||||
tp2 = close - 2.5 * atr_val
|
||||
tp3 = close - 4.0 * atr_val
|
||||
|
||||
return {
|
||||
"direction": "SHORT",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.40, 0.40, 0.20),
|
||||
"trail_atr_mult": 2.0,
|
||||
"max_bars": 150,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _calc_confluence(self, current, data, idx, direction, touch_count):
|
||||
confluence = 1 # breakout confirmed
|
||||
|
||||
# More touches = stronger level
|
||||
if touch_count >= 3:
|
||||
confluence += 1
|
||||
if touch_count >= 5:
|
||||
confluence += 1
|
||||
|
||||
rsi = current.get("rsi_14", 50)
|
||||
if direction == "LONG" and 50 < rsi < 75:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and 25 < rsi < 50:
|
||||
confluence += 1
|
||||
|
||||
ema_50 = current.get("ema_50", 0)
|
||||
ema_200 = current.get("ema_200", 0)
|
||||
if ema_50 and ema_200:
|
||||
if direction == "LONG" and ema_50 > ema_200:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and ema_50 < ema_200:
|
||||
confluence += 1
|
||||
|
||||
return min(confluence, 5)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Strategy 4: EMA Ribbon Momentum Scalp.
|
||||
|
||||
Entry TF: M15, Filter TF: H1.
|
||||
|
||||
H1 Filter: EMA 20 > 50 > 100 > 200 (LONG) or reversed (SHORT)
|
||||
|
||||
M15 Entry (LONG):
|
||||
- EMA ribbon compressed (EMAs within 1.0x ATR)
|
||||
- Ribbon re-expanding (current width > prev width)
|
||||
- Stochastic turning from oversold
|
||||
- Session filter: London/NY (08:00-17:00 UTC)
|
||||
|
||||
SL: Below compression low - 0.5x ATR
|
||||
TP1: 1x ATR, TP2: 1.5x ATR, TP3: 2.5x ATR
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S4_EMA_Ribbon(BaseStrategy):
|
||||
strategy_id = 4
|
||||
name = "S4_EMA_Ribbon_Scalp"
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 50:
|
||||
return None
|
||||
|
||||
# Session filter
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 17:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
if htf_row is None:
|
||||
return None
|
||||
|
||||
# H1 EMA stack check
|
||||
htf_ema20 = htf_row.get("ema_20", np.nan)
|
||||
htf_ema50 = htf_row.get("ema_50", np.nan)
|
||||
htf_ema100 = htf_row.get("ema_100", np.nan)
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
if any(np.isnan(v) for v in [htf_ema20, htf_ema50, htf_ema100, htf_ema200]):
|
||||
return None
|
||||
|
||||
long_stack = htf_ema20 > htf_ema50 > htf_ema100 > htf_ema200
|
||||
short_stack = htf_ema20 < htf_ema50 < htf_ema100 < htf_ema200
|
||||
|
||||
if not long_stack and not short_stack:
|
||||
return None
|
||||
|
||||
# M15 ribbon EMAs
|
||||
ema_20 = current.get("ema_20", np.nan)
|
||||
ema_50 = current.get("ema_50", np.nan)
|
||||
ema_100 = current.get("ema_100", np.nan)
|
||||
if any(np.isnan(v) for v in [ema_20, ema_50, ema_100]):
|
||||
return None
|
||||
|
||||
ribbon_width = max(ema_20, ema_50, ema_100) - min(ema_20, ema_50, ema_100)
|
||||
compression_threshold = 1.0 * atr_val # relaxed from 0.5x
|
||||
|
||||
# Check for recent compression (look back 5-20 bars)
|
||||
was_compressed = False
|
||||
compression_low = current["low"]
|
||||
compression_high = current["high"]
|
||||
min_compression_width = float('inf')
|
||||
|
||||
for j in range(max(0, idx - 20), idx):
|
||||
bar = data.iloc[j]
|
||||
e20 = bar.get("ema_20", np.nan)
|
||||
e50 = bar.get("ema_50", np.nan)
|
||||
e100 = bar.get("ema_100", np.nan)
|
||||
if any(np.isnan(v) for v in [e20, e50, e100]):
|
||||
continue
|
||||
w = max(e20, e50, e100) - min(e20, e50, e100)
|
||||
if w <= compression_threshold:
|
||||
was_compressed = True
|
||||
min_compression_width = min(min_compression_width, w)
|
||||
compression_low = min(compression_low, bar["low"])
|
||||
compression_high = max(compression_high, bar["high"])
|
||||
|
||||
if not was_compressed:
|
||||
return None
|
||||
|
||||
# Current bar: ribbon must be expanding (wider than min compression)
|
||||
if ribbon_width <= min_compression_width * 1.2:
|
||||
return None
|
||||
|
||||
# Stochastic
|
||||
stoch_k = current.get("stoch_k", 50)
|
||||
stoch_d = current.get("stoch_d", 50)
|
||||
|
||||
if long_stack:
|
||||
# Stochastic turning up or in bullish zone
|
||||
if not (stoch_k > stoch_d or stoch_k < 50):
|
||||
return None
|
||||
if not (ema_20 >= ema_50): # Ribbon expanding upward
|
||||
return None
|
||||
|
||||
confluence = self._calc_confluence(data, idx, current, atr_val, "LONG")
|
||||
|
||||
# Use tighter SL: max of (compression_low, close - 0.8*ATR)
|
||||
sl_compression = compression_low - 0.3 * atr_val
|
||||
sl_atr = current["close"] - 0.8 * atr_val
|
||||
sl = max(sl_compression, sl_atr)
|
||||
tp1 = current["close"] + 1.0 * atr_val
|
||||
tp2 = current["close"] + 1.5 * atr_val
|
||||
tp3 = current["close"] + 2.5 * atr_val
|
||||
|
||||
return {
|
||||
"direction": "LONG",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.50, 0.30, 0.20),
|
||||
"trail_atr_mult": 1.0,
|
||||
"max_bars": 60,
|
||||
}
|
||||
|
||||
elif short_stack:
|
||||
if not (stoch_k < stoch_d or stoch_k > 50):
|
||||
return None
|
||||
if not (ema_20 <= ema_50):
|
||||
return None
|
||||
|
||||
confluence = self._calc_confluence(data, idx, current, atr_val, "SHORT")
|
||||
|
||||
sl_compression = compression_high + 0.3 * atr_val
|
||||
sl_atr = current["close"] + 0.8 * atr_val
|
||||
sl = min(sl_compression, sl_atr)
|
||||
tp1 = current["close"] - 1.0 * atr_val
|
||||
tp2 = current["close"] - 1.5 * atr_val
|
||||
tp3 = current["close"] - 2.5 * atr_val
|
||||
|
||||
return {
|
||||
"direction": "SHORT",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.50, 0.30, 0.20),
|
||||
"trail_atr_mult": 1.0,
|
||||
"max_bars": 60,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _calc_confluence(self, data, idx, current, atr_val, direction):
|
||||
confluence = 2 # HTF alignment + compression/expansion
|
||||
|
||||
# ATR contracting
|
||||
atr_avg = data["atr_14"].iloc[max(0, idx - 50):idx].mean()
|
||||
if atr_val < atr_avg:
|
||||
confluence += 1
|
||||
|
||||
# Volume rising
|
||||
if "volume" in current.index:
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and current["volume"] > vol_avg:
|
||||
confluence += 1
|
||||
|
||||
# RSI not extreme
|
||||
rsi = current.get("rsi_14", 50)
|
||||
if direction == "LONG" and 40 < rsi < 65:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and 35 < rsi < 60:
|
||||
confluence += 1
|
||||
|
||||
return min(confluence, 5)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Strategy 5: Momentum Exhaustion Reversal.
|
||||
|
||||
Entry TF: M15, Filter TF: H1 (structure/key levels).
|
||||
|
||||
Mandatory conditions (all 4 required):
|
||||
1. RSI divergence (price new extreme, RSI doesn't confirm)
|
||||
2. MACD histogram shrinking (2+ consecutive smaller bars)
|
||||
3. Near key level (within 0.75x ATR)
|
||||
4. Price overextended: moved 1.0x+ ATR from nearest EMA
|
||||
- Session filter: London/NY (08:00-17:00 UTC)
|
||||
|
||||
Mutual exclusion: Cannot fire if S4 ribbon is compressed.
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
from ..indicators.technical import identify_key_levels
|
||||
|
||||
|
||||
class S5_Momentum_Exhaustion(BaseStrategy):
|
||||
strategy_id = 5
|
||||
name = "S5_Momentum_Exhaustion"
|
||||
|
||||
def __init__(self):
|
||||
self._cached_levels = None
|
||||
self._cache_idx = -1
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 50:
|
||||
return None
|
||||
|
||||
# Session filter
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 17:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# Condition 1: RSI divergence
|
||||
divergence = self._detect_divergence(data, idx)
|
||||
if divergence is None:
|
||||
return None
|
||||
|
||||
# Condition 2: MACD histogram shrinking
|
||||
if not self._macd_shrinking(data, idx):
|
||||
return None
|
||||
|
||||
# Condition 3: Near key level
|
||||
if not self._near_key_level(data, idx, current, atr_val):
|
||||
return None
|
||||
|
||||
# Condition 4: Overextension from EMA
|
||||
if not self._overextended(current, atr_val):
|
||||
return None
|
||||
|
||||
# Mutual exclusion with S4 ribbon
|
||||
if self._s4_active(current, atr_val):
|
||||
return None
|
||||
|
||||
confluence = 4 # All mandatory met
|
||||
|
||||
# Booster: declining volume (required for entry - reduces false signals)
|
||||
if not self._volume_declining(data, idx):
|
||||
return None
|
||||
confluence = 5
|
||||
|
||||
close = current["close"]
|
||||
if divergence == "bullish":
|
||||
sl = current["low"] - 0.5 * atr_val # Tight SL for reversal
|
||||
tp1 = close + 1.5 * atr_val
|
||||
tp2 = close + 2.5 * atr_val
|
||||
tp3 = close + 4.0 * atr_val
|
||||
direction = "LONG"
|
||||
else:
|
||||
sl = current["high"] + 0.5 * atr_val # Tight SL for reversal
|
||||
tp1 = close - 1.5 * atr_val
|
||||
tp2 = close - 2.5 * atr_val
|
||||
tp3 = close - 4.0 * atr_val
|
||||
direction = "SHORT"
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.40, 0.40, 0.20),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": 120,
|
||||
}
|
||||
|
||||
def _detect_divergence(self, data, idx):
|
||||
"""Detect RSI divergence over lookback window."""
|
||||
lookback = 20
|
||||
if idx < lookback + 3:
|
||||
return None
|
||||
|
||||
rsi_col = "rsi_14"
|
||||
if rsi_col not in data.columns:
|
||||
return None
|
||||
|
||||
window = data.iloc[idx - lookback:idx + 1]
|
||||
rsi_vals = window[rsi_col]
|
||||
lows = window["low"]
|
||||
highs = window["high"]
|
||||
|
||||
# Bullish: current low is near the lowest in window, but RSI is higher
|
||||
current_low = data.iloc[idx]["low"]
|
||||
min_low_idx = lows.iloc[:-3].idxmin() # exclude last 3 bars
|
||||
if min_low_idx is not None:
|
||||
prev_low = lows.loc[min_low_idx]
|
||||
if current_low <= prev_low * 1.001: # current near or below prev low
|
||||
if rsi_vals.iloc[-1] > rsi_vals.loc[min_low_idx]:
|
||||
return "bullish"
|
||||
|
||||
# Bearish: current high is near highest, but RSI is lower
|
||||
current_high = data.iloc[idx]["high"]
|
||||
max_high_idx = highs.iloc[:-3].idxmax()
|
||||
if max_high_idx is not None:
|
||||
prev_high = highs.loc[max_high_idx]
|
||||
if current_high >= prev_high * 0.999:
|
||||
if rsi_vals.iloc[-1] < rsi_vals.loc[max_high_idx]:
|
||||
return "bearish"
|
||||
|
||||
return None
|
||||
|
||||
def _macd_shrinking(self, data, idx):
|
||||
if idx < 3:
|
||||
return False
|
||||
h0 = abs(data.iloc[idx].get("macd_hist", 0))
|
||||
h1 = abs(data.iloc[idx - 1].get("macd_hist", 0))
|
||||
h2 = abs(data.iloc[idx - 2].get("macd_hist", 0))
|
||||
return h0 < h1 and h1 < h2 # 2 consecutive shrinks
|
||||
|
||||
def _near_key_level(self, data, idx, current, atr_val):
|
||||
# Recache every 50 bars
|
||||
if self._cached_levels is None or idx - self._cache_idx >= 50:
|
||||
start = max(0, idx - 500)
|
||||
window = data.iloc[start:idx]
|
||||
self._cached_levels = identify_key_levels(
|
||||
window, lookback=5, tolerance_atr_mult=0.75, min_touches=2
|
||||
)
|
||||
self._cache_idx = idx
|
||||
|
||||
if not self._cached_levels:
|
||||
return False
|
||||
|
||||
close = current["close"]
|
||||
tolerance = 0.75 * atr_val # relaxed from 0.5x
|
||||
|
||||
for level_price, _ in self._cached_levels:
|
||||
if abs(close - level_price) <= tolerance:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _overextended(self, current, atr_val):
|
||||
close = current["close"]
|
||||
ema_50 = current.get("ema_50", np.nan)
|
||||
ema_100 = current.get("ema_100", np.nan)
|
||||
|
||||
distances = []
|
||||
if not np.isnan(ema_50):
|
||||
distances.append(abs(close - ema_50))
|
||||
if not np.isnan(ema_100):
|
||||
distances.append(abs(close - ema_100))
|
||||
|
||||
if not distances:
|
||||
return False
|
||||
|
||||
# Relaxed from 1.5x to 1.0x ATR
|
||||
return min(distances) >= 1.0 * atr_val
|
||||
|
||||
def _s4_active(self, current, atr_val):
|
||||
ema_20 = current.get("ema_20", np.nan)
|
||||
ema_50 = current.get("ema_50", np.nan)
|
||||
ema_100 = current.get("ema_100", np.nan)
|
||||
if any(np.isnan(v) for v in [ema_20, ema_50, ema_100]):
|
||||
return False
|
||||
ribbon_width = max(ema_20, ema_50, ema_100) - min(ema_20, ema_50, ema_100)
|
||||
return ribbon_width <= 1.0 * atr_val # match S4's relaxed threshold
|
||||
|
||||
def _volume_declining(self, data, idx):
|
||||
if idx < 5 or "volume" not in data.columns:
|
||||
return False
|
||||
vols = data["volume"].iloc[idx - 4:idx + 1].values
|
||||
if np.any(np.isnan(vols)):
|
||||
return False
|
||||
diffs = np.diff(vols)
|
||||
return np.sum(diffs < 0) >= 3
|
||||
Reference in New Issue
Block a user