Files
fx-quant/src/strategies_pkg/s4f_ema_ribbon.py
T
Brent NealeandClaude Opus 4.6 edbe359d1b Phase 1 complete: S3-S6 strategies, S4 variant analysis, learnings doc
- S3 Key Level Breakout: best performer (52-53% WR, PF ~1.0 on JPY crosses)
- S4 EMA Ribbon: tested 7 variants (D/E/F/F-v2/G/G-Minimal), exhausted
  - Only EUR_AUD S4-F marginally profitable (PF 1.06)
  - Detailed filter funnel analysis revealed contradictory filter stacking
- S5 Momentum Exhaustion: extended to 5 pairs, PF 0.43-0.77
- S6 EMA Bounce: 59-60% WR but PF 0.83-0.84, needs SL/TP restructuring
- Added STRATEGY_LEARNINGS.md with design principles and next steps
- Added M5 data downloader for 3-timeframe strategies
- Updated README with full strategy scorecard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:42:16 +10:00

146 lines
4.9 KiB
Python

"""
Strategy S4-F: EMA Ribbon — Trend Context Filter.
Entry: Current S4 ribbon logic PLUS:
- 1H trend for LONG: 1H close > 1H 200 EMA AND 1H 50 EMA > 1H 200 EMA
- 1H trend for SHORT: 1H close < 1H 200 EMA AND 1H 50 EMA < 1H 200 EMA
- Price within 1.5 ATR of 1H 50 EMA
- 15min EMA stacking for LONG: 8 EMA > 13 EMA AND 13 EMA > 21 EMA
- 15min EMA stacking for SHORT: 8 EMA < 13 EMA AND 13 EMA < 21 EMA
- Volume > 1.2x average
Exit: Same as S4-D (SL 2.0 ATR, TP 3.0 ATR, 100% close, no partials)
"""
from typing import Optional
import numpy as np
import pandas as pd
from .base import BaseStrategy
class S4F_EMA_Ribbon(BaseStrategy):
strategy_id = 4
name = "S4F_Trend_Context"
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
hour = current.name.hour if hasattr(current.name, 'hour') else 0
if hour < 8 or hour >= 16:
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
# ------- ORIGINAL H1 EMA STACK (20 > 50 > 100 > 200) -------
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
# ------- NEW 1H TREND FILTER -------
htf_close = htf_row.get("close", np.nan)
if np.isnan(htf_close):
return None
if long_stack:
if not (htf_close > htf_ema200 and htf_ema50 > htf_ema200):
return None
direction = "LONG"
else:
if not (htf_close < htf_ema200 and htf_ema50 < htf_ema200):
return None
direction = "SHORT"
# ------- PRICE WITHIN 1.5 ATR OF 1H 50 EMA -------
price = current["close"]
if abs(price - htf_ema50) > 1.5 * atr_val:
return None
# ------- M15 RIBBON COMPRESSION -> EXPANSION -------
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
was_compressed = False
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)
if not was_compressed:
return None
if ribbon_width <= min_compression_width * 1.2:
return None
# ------- 15MIN EMA STACKING: 8 > 13 > 21 (LONG) or reversed -------
ema_8 = current.get("ema_8", np.nan)
ema_13 = current.get("ema_13", np.nan)
ema_21 = current.get("ema_21", np.nan)
if any(np.isnan(v) for v in [ema_8, ema_13, ema_21]):
return None
if direction == "LONG":
if not (ema_8 > ema_13 and ema_13 > ema_21):
return None
else:
if not (ema_8 < ema_13 and ema_13 < ema_21):
return None
# ------- VOLUME > 1.2x average -------
if "volume" not in current.index:
return None
vol = current["volume"]
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
if vol_avg <= 0 or vol <= 1.2 * vol_avg:
return None
# ------- EXIT LEVELS -------
if direction == "LONG":
sl = price - 2.0 * atr_val
tp1 = price + 3.0 * atr_val
else:
sl = price + 2.0 * atr_val
tp1 = price - 3.0 * atr_val
return {
"direction": direction,
"sl": sl,
"tp1": tp1,
"tp2": tp1,
"tp3": tp1,
"confluence": 3,
"entry_pattern": "ribbon_trend_context",
"tp_splits": (1.0, 0.0, 0.0),
"trail_atr_mult": 0,
"max_bars": 60,
"no_breakeven": True,
}