mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-23 15:08:05 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
dce54845c2
commit
edbe359d1b
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user