mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-16 19:48:06 +00:00
Phase 1 complete: S7-S9 Smart Money strategies, expanded pair testing, consolidated scorecard
- S7 Liquidity Sweep: built, tested across 6 pairs, tight SL (1.0 ATR) on GBP_JPY is Phase 2 candidate (107 trades, OOS PF 1.39, gen ratio 1.81) - S8 Order Block: built, tested on GBP_JPY (watchlist, 32 trades, OOS PF 1.55) - S9 London Session: built, tested across 8 pairs with filter experiments GBP_USD (OOS PF 1.45) and GBP_AUD filtered (OOS PF 1.94) advance to Phase 2 - Added OBV indicator to technical.py - Added GBP_NZD to engine spread/pip config - Standalone OANDA fetcher (bypasses Supabase dependency) - Fetched EUR_GBP, EUR_USD, GBP_NZD H1 data (2021-2023) - Consolidated STRATEGY_LEARNINGS.md with full Phase 1 scorecard and 11 design principles - Phase 2 roster: S7/GBP_JPY, S9/GBP_USD, S9F/GBP_AUD, S4-F/EUR_AUD, S3/GBP_JPY Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
70a216d695
commit
39a6536284
@@ -5,6 +5,9 @@ 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
|
||||
from .s6_ema_bounce import S6_EMA_Bounce
|
||||
from .s7_liquidity_sweep import S7_Liquidity_Sweep
|
||||
from .s8_order_block import S8_Order_Block
|
||||
from .s9_london_session import S9_London_Session
|
||||
|
||||
STRATEGIES = {
|
||||
1: S1_MA_Breakout,
|
||||
@@ -13,6 +16,9 @@ STRATEGIES = {
|
||||
4: S4_EMA_Ribbon,
|
||||
5: S5_Momentum_Exhaustion,
|
||||
6: S6_EMA_Bounce,
|
||||
7: S7_Liquidity_Sweep,
|
||||
8: S8_Order_Block,
|
||||
9: S9_London_Session,
|
||||
}
|
||||
|
||||
# Which pairs each strategy trades
|
||||
@@ -25,6 +31,9 @@ STRATEGY_PAIRS = {
|
||||
4: ["GBP_AUD", "EUR_AUD", "GBP_JPY"],
|
||||
5: ["GBP_AUD", "EUR_AUD", "GBP_JPY", "USD_JPY", "GBP_USD"],
|
||||
6: ["GBP_AUD"], # Initial test — expand to EUR_AUD, GBP_USD if passing
|
||||
7: ["GBP_USD", "GBP_JPY", "EUR_AUD"],
|
||||
8: ["GBP_USD", "EUR_AUD", "GBP_JPY"],
|
||||
9: ["GBP_USD", "EUR_AUD", "GBP_JPY"],
|
||||
}
|
||||
|
||||
# Primary and filter timeframes
|
||||
@@ -35,4 +44,7 @@ STRATEGY_TIMEFRAMES = {
|
||||
4: {"primary": "M15", "filter": "H1"},
|
||||
5: {"primary": "M15", "filter": "H1"},
|
||||
6: {"primary": "M15", "filter": "H1"},
|
||||
7: {"primary": "H1", "filter": None}, # H1 primary, internal HTF via htf_data
|
||||
8: {"primary": "H1", "filter": None}, # H1 primary, internal HTF via htf_data
|
||||
9: {"primary": "H1", "filter": None}, # H1 primary, internal HTF via htf_data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Strategy S7: Liquidity Sweep Reversal.
|
||||
|
||||
Concept: Price sweeps past a significant swing high/low (triggering clustered
|
||||
stop-loss orders), then reverses. The most empirically-supported SMC concept
|
||||
(Osler 2005, NY Fed).
|
||||
|
||||
Entry conditions (ALL must be true):
|
||||
1. Identify significant swing high/low (5-bar fractal) within last 100 bars
|
||||
2. Price penetrates the swing level by 0.7-1.0 ATR (the sweep)
|
||||
3. Price closes back inside the previous range (reversal candle)
|
||||
4. OBV divergence: price makes new extreme but OBV doesn't confirm
|
||||
(institutional absorption signal)
|
||||
5. HTF (H1) trend alignment: only take sweeps in the direction of
|
||||
the higher-timeframe trend (200 EMA bias)
|
||||
6. Session filter: London/NY hours (08:00-17:00 UTC)
|
||||
7. RSI < 35 (for longs) or > 65 (for shorts) as a filter
|
||||
|
||||
Exit:
|
||||
- SL: 1.5 ATR beyond the sweep extreme
|
||||
- TP1: 1.5 ATR from entry (close 50%)
|
||||
- TP2: 3.0 ATR from entry (close 50%)
|
||||
- Max hold: 40 bars (H1 = ~40 hours)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S7_Liquidity_Sweep(BaseStrategy):
|
||||
strategy_id = 7
|
||||
name = "S7_Liquidity_Sweep"
|
||||
|
||||
# Tunable parameters
|
||||
SWING_LOOKBACK = 5 # N-bar fractal for swing detection
|
||||
SWING_HISTORY = 100 # How far back to search for swing levels
|
||||
SWEEP_MIN_ATR = 0.7 # Minimum penetration for a sweep
|
||||
SWEEP_MAX_ATR = 1.5 # Above this = genuine breakout, not a sweep
|
||||
SL_ATR_MULT = 1.0 # SL beyond sweep extreme (tightened from 1.5)
|
||||
TP1_ATR_MULT = 1.5 # First take-profit
|
||||
TP2_ATR_MULT = 3.0 # Second take-profit
|
||||
OBV_LOOKBACK = 20 # Lookback for OBV divergence detection
|
||||
MAX_BARS = 40
|
||||
|
||||
def _find_swing_levels(self, data, idx):
|
||||
"""Find significant swing highs and lows within lookback window."""
|
||||
start = max(0, idx - self.SWING_HISTORY)
|
||||
# Exclude the very recent bars (last 3) to avoid detecting current price action
|
||||
end = idx - 2
|
||||
if end - start < 20:
|
||||
return [], []
|
||||
|
||||
swing_highs = []
|
||||
swing_lows = []
|
||||
lb = self.SWING_LOOKBACK
|
||||
|
||||
for i in range(start + lb, end - lb + 1):
|
||||
# Swing high: highest high in [i-lb, i+lb]
|
||||
window_highs = data["high"].iloc[i - lb:i + lb + 1]
|
||||
if data["high"].iloc[i] == window_highs.max():
|
||||
swing_highs.append((i, data["high"].iloc[i]))
|
||||
|
||||
# Swing low: lowest low in [i-lb, i+lb]
|
||||
window_lows = data["low"].iloc[i - lb:i + lb + 1]
|
||||
if data["low"].iloc[i] == window_lows.min():
|
||||
swing_lows.append((i, data["low"].iloc[i]))
|
||||
|
||||
return swing_highs, swing_lows
|
||||
|
||||
def _find_equal_levels(self, levels, atr_val):
|
||||
"""Find clusters of equal highs/lows (within 0.1 ATR) — highest probability targets."""
|
||||
if len(levels) < 2:
|
||||
return levels
|
||||
tolerance = 0.1 * atr_val
|
||||
clustered = []
|
||||
used = set()
|
||||
for i, (idx_i, price_i) in enumerate(levels):
|
||||
if i in used:
|
||||
continue
|
||||
cluster = [(idx_i, price_i)]
|
||||
used.add(i)
|
||||
for j, (idx_j, price_j) in enumerate(levels):
|
||||
if j in used:
|
||||
continue
|
||||
if abs(price_j - price_i) <= tolerance:
|
||||
cluster.append((idx_j, price_j))
|
||||
used.add(j)
|
||||
if len(cluster) >= 2:
|
||||
# Use the average price for the cluster, latest index
|
||||
avg_price = np.mean([p for _, p in cluster])
|
||||
latest_idx = max(idx for idx, _ in cluster)
|
||||
clustered.append((latest_idx, avg_price))
|
||||
else:
|
||||
clustered.append((idx_i, price_i))
|
||||
return clustered
|
||||
|
||||
def _check_obv_divergence(self, data, idx, direction):
|
||||
"""Check for OBV divergence (institutional absorption signal)."""
|
||||
lb = self.OBV_LOOKBACK
|
||||
if idx < lb:
|
||||
return False
|
||||
|
||||
window = data.iloc[idx - lb:idx + 1]
|
||||
obv_vals = window.get("obv")
|
||||
if obv_vals is None:
|
||||
return False
|
||||
|
||||
if direction == "LONG":
|
||||
# Bullish OBV divergence: price makes lower low but OBV makes higher low
|
||||
price_lows = window["low"]
|
||||
recent_low_pos = price_lows.values.argmin()
|
||||
if recent_low_pos < lb - 5:
|
||||
return False # Low isn't recent enough
|
||||
# Find previous low in first half of window
|
||||
first_half = price_lows.iloc[:lb // 2]
|
||||
if len(first_half) < 3:
|
||||
return False
|
||||
prev_low_pos = first_half.values.argmin()
|
||||
if (price_lows.iloc[recent_low_pos] < first_half.iloc[prev_low_pos] and
|
||||
obv_vals.iloc[recent_low_pos] > obv_vals.iloc[prev_low_pos]):
|
||||
return True
|
||||
else:
|
||||
# Bearish OBV divergence: price makes higher high but OBV makes lower high
|
||||
price_highs = window["high"]
|
||||
recent_high_pos = price_highs.values.argmax()
|
||||
if recent_high_pos < lb - 5:
|
||||
return False
|
||||
first_half = price_highs.iloc[:lb // 2]
|
||||
if len(first_half) < 3:
|
||||
return False
|
||||
prev_high_pos = first_half.values.argmax()
|
||||
if (price_highs.iloc[recent_high_pos] > first_half.iloc[prev_high_pos] and
|
||||
obv_vals.iloc[recent_high_pos] < obv_vals.iloc[prev_high_pos]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 200:
|
||||
return None
|
||||
|
||||
# Session filter: 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
|
||||
|
||||
# HTF trend alignment
|
||||
if htf_row is None:
|
||||
return None
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if np.isnan(htf_ema200) or np.isnan(htf_close):
|
||||
return None
|
||||
|
||||
htf_bullish = htf_close > htf_ema200
|
||||
htf_bearish = htf_close < htf_ema200
|
||||
|
||||
price = current["close"]
|
||||
candle_high = current["high"]
|
||||
candle_low = current["low"]
|
||||
|
||||
# RSI as confluence signal (not hard gate — lesson from S4)
|
||||
rsi_val = current.get("rsi_14", 50)
|
||||
if np.isnan(rsi_val):
|
||||
rsi_val = 50
|
||||
|
||||
# Find swing levels
|
||||
swing_highs, swing_lows = self._find_swing_levels(data, idx)
|
||||
|
||||
# ---- CHECK FOR BULLISH SWEEP (sweep below swing low, then reverse up) ----
|
||||
if htf_bullish:
|
||||
swing_lows = self._find_equal_levels(swing_lows, atr_val)
|
||||
for sw_idx, sw_price in reversed(swing_lows): # Check most recent first
|
||||
penetration = sw_price - candle_low
|
||||
if penetration < self.SWEEP_MIN_ATR * atr_val:
|
||||
continue
|
||||
if penetration > self.SWEEP_MAX_ATR * atr_val:
|
||||
continue
|
||||
# Reversal confirmation: close back above the swing level
|
||||
if price <= sw_price:
|
||||
continue
|
||||
# Strong close: in upper 40% of candle range
|
||||
candle_range = candle_high - candle_low
|
||||
if candle_range <= 0:
|
||||
continue
|
||||
if (price - candle_low) / candle_range < 0.4:
|
||||
continue
|
||||
|
||||
# OBV divergence check (bonus confluence, not hard gate)
|
||||
obv_div = self._check_obv_divergence(data, idx, "LONG")
|
||||
confluence = 3 + (1 if obv_div else 0)
|
||||
|
||||
# RSI in oversold zone adds confluence (soft, not hard gate)
|
||||
if rsi_val < 40:
|
||||
confluence += 1
|
||||
|
||||
# Volume confirmation
|
||||
vol = current.get("volume", 0)
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and vol > 1.5 * vol_avg:
|
||||
confluence += 1
|
||||
|
||||
sweep_extreme = candle_low
|
||||
sl = sweep_extreme - self.SL_ATR_MULT * atr_val
|
||||
tp1 = price + self.TP1_ATR_MULT * atr_val
|
||||
tp2 = price + self.TP2_ATR_MULT * atr_val
|
||||
|
||||
return {
|
||||
"direction": "LONG",
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": "liquidity_sweep_bullish",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
|
||||
# ---- CHECK FOR BEARISH SWEEP (sweep above swing high, then reverse down) ----
|
||||
if htf_bearish:
|
||||
swing_highs = self._find_equal_levels(swing_highs, atr_val)
|
||||
for sw_idx, sw_price in reversed(swing_highs):
|
||||
penetration = candle_high - sw_price
|
||||
if penetration < self.SWEEP_MIN_ATR * atr_val:
|
||||
continue
|
||||
if penetration > self.SWEEP_MAX_ATR * atr_val:
|
||||
continue
|
||||
# Reversal: close back below the swing level
|
||||
if price >= sw_price:
|
||||
continue
|
||||
# Strong close: in lower 40% of candle range
|
||||
candle_range = candle_high - candle_low
|
||||
if candle_range <= 0:
|
||||
continue
|
||||
if (candle_high - price) / candle_range < 0.4:
|
||||
continue
|
||||
|
||||
obv_div = self._check_obv_divergence(data, idx, "SHORT")
|
||||
confluence = 3 + (1 if obv_div else 0)
|
||||
|
||||
if rsi_val > 60:
|
||||
confluence += 1
|
||||
|
||||
vol = current.get("volume", 0)
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and vol > 1.5 * vol_avg:
|
||||
confluence += 1
|
||||
|
||||
sweep_extreme = candle_high
|
||||
sl = sweep_extreme + self.SL_ATR_MULT * atr_val
|
||||
tp1 = price - self.TP1_ATR_MULT * atr_val
|
||||
tp2 = price - self.TP2_ATR_MULT * atr_val
|
||||
|
||||
return {
|
||||
"direction": "SHORT",
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": "liquidity_sweep_bearish",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Strategy S8: Order Block Retest.
|
||||
|
||||
Concept: Price returns to the last opposing candle before a strong impulse
|
||||
move (displacement), and bounces from it. Treated as classical supply/demand
|
||||
zones with strict confluence filters (not mythical "institutional footprints").
|
||||
|
||||
Entry conditions (ALL must be true):
|
||||
1. Detect a displacement candle: body >= 1.5 ATR (strong impulse)
|
||||
2. Identify the order block: last opposing candle before displacement
|
||||
3. Price returns to retest the OB zone (touches OB body range)
|
||||
4. Rejection candle at OB: pin bar (wick >= 2x body) or engulfing pattern
|
||||
5. At least 2 of 3 confluence factors:
|
||||
a) FVG exists within the impulse move
|
||||
b) OB is at a broken S/R level (structural confluence)
|
||||
c) Volume declining on pullback into OB
|
||||
6. HTF (H1) trend alignment via 200 EMA
|
||||
7. Session filter: 08:00-17:00 UTC
|
||||
|
||||
Exit:
|
||||
- SL: OB body low/high + 0.3 ATR buffer (NOT the full wick)
|
||||
- TP1: 1.5 ATR from entry (close 50%)
|
||||
- TP2: 3.0 ATR from entry (close 50%)
|
||||
- Max hold: 40 bars
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S8_Order_Block(BaseStrategy):
|
||||
strategy_id = 8
|
||||
name = "S8_Order_Block"
|
||||
|
||||
# Tunable parameters
|
||||
DISPLACEMENT_ATR = 1.5 # Min body size for displacement candle
|
||||
DISPLACEMENT_VOL = 1.5 # Min volume ratio for displacement
|
||||
OB_LOOKBACK = 50 # How far back to search for OBs
|
||||
OB_RETEST_WINDOW = 30 # Max bars for price to retest OB after displacement
|
||||
SL_ATR_BUFFER = 0.3 # Buffer beyond OB body for SL
|
||||
TP1_ATR_MULT = 1.5
|
||||
TP2_ATR_MULT = 3.0
|
||||
MAX_BARS = 40
|
||||
|
||||
def _find_order_blocks(self, data, idx, atr_val):
|
||||
"""
|
||||
Find valid order blocks: last opposing candle before a displacement move.
|
||||
Returns list of dicts: {direction, ob_idx, ob_body_high, ob_body_low,
|
||||
ob_high, ob_low, displacement_idx, has_fvg}
|
||||
"""
|
||||
order_blocks = []
|
||||
start = max(0, idx - self.OB_LOOKBACK)
|
||||
|
||||
for i in range(start + 1, idx - 2):
|
||||
curr = data.iloc[i]
|
||||
body = abs(curr["close"] - curr["open"])
|
||||
|
||||
# Is this a displacement candle? (body >= 1.5 ATR)
|
||||
bar_atr = curr.get("atr_14", atr_val)
|
||||
if np.isnan(bar_atr) or bar_atr <= 0:
|
||||
bar_atr = atr_val
|
||||
if body < self.DISPLACEMENT_ATR * bar_atr:
|
||||
continue
|
||||
|
||||
# Volume confirmation for displacement
|
||||
vol = curr.get("volume", 0)
|
||||
vol_avg = data["volume"].iloc[max(0, i - 20):i].mean()
|
||||
if vol_avg > 0 and vol < self.DISPLACEMENT_VOL * vol_avg:
|
||||
continue
|
||||
|
||||
is_bullish_displacement = curr["close"] > curr["open"]
|
||||
is_bearish_displacement = curr["close"] < curr["open"]
|
||||
|
||||
if not (is_bullish_displacement or is_bearish_displacement):
|
||||
continue
|
||||
|
||||
# Find the order block: last OPPOSING candle before displacement
|
||||
ob_idx = None
|
||||
for j in range(i - 1, max(start, i - 10) - 1, -1):
|
||||
ob_candle = data.iloc[j]
|
||||
ob_bullish = ob_candle["close"] > ob_candle["open"]
|
||||
ob_bearish = ob_candle["close"] < ob_candle["open"]
|
||||
|
||||
if is_bullish_displacement and ob_bearish:
|
||||
ob_idx = j
|
||||
break
|
||||
elif is_bearish_displacement and ob_bullish:
|
||||
ob_idx = j
|
||||
break
|
||||
|
||||
if ob_idx is None:
|
||||
continue
|
||||
|
||||
ob = data.iloc[ob_idx]
|
||||
ob_body_high = max(ob["open"], ob["close"])
|
||||
ob_body_low = min(ob["open"], ob["close"])
|
||||
|
||||
# Check for FVG in the impulse move
|
||||
has_fvg = False
|
||||
if i >= 2:
|
||||
candle_before = data.iloc[i - 1]
|
||||
candle_after_idx = min(i + 1, len(data) - 1)
|
||||
candle_after = data.iloc[candle_after_idx]
|
||||
if is_bullish_displacement:
|
||||
# Bullish FVG: candle[i-1].high < candle[i+1].low
|
||||
if candle_before["high"] < candle_after["low"]:
|
||||
has_fvg = True
|
||||
else:
|
||||
# Bearish FVG: candle[i-1].low > candle[i+1].high
|
||||
if candle_before["low"] > candle_after["high"]:
|
||||
has_fvg = True
|
||||
|
||||
direction = "LONG" if is_bullish_displacement else "SHORT"
|
||||
order_blocks.append({
|
||||
"direction": direction,
|
||||
"ob_idx": ob_idx,
|
||||
"ob_body_high": ob_body_high,
|
||||
"ob_body_low": ob_body_low,
|
||||
"ob_high": ob["high"],
|
||||
"ob_low": ob["low"],
|
||||
"displacement_idx": i,
|
||||
"has_fvg": has_fvg,
|
||||
})
|
||||
|
||||
return order_blocks
|
||||
|
||||
def _is_rejection_candle(self, candle, direction):
|
||||
"""Check if candle shows rejection (pin bar or engulfing-like)."""
|
||||
body = abs(candle["close"] - candle["open"])
|
||||
full_range = candle["high"] - candle["low"]
|
||||
if full_range <= 0:
|
||||
return False
|
||||
|
||||
if direction == "LONG":
|
||||
lower_wick = min(candle["open"], candle["close"]) - candle["low"]
|
||||
# Pin bar: lower wick >= 2x body, close in upper 40%
|
||||
if lower_wick >= 2 * body and (candle["close"] - candle["low"]) / full_range >= 0.6:
|
||||
return True
|
||||
# Bullish candle with strong close
|
||||
if candle["close"] > candle["open"] and body / full_range >= 0.5:
|
||||
return True
|
||||
else:
|
||||
upper_wick = candle["high"] - max(candle["open"], candle["close"])
|
||||
# Pin bar: upper wick >= 2x body, close in lower 40%
|
||||
if upper_wick >= 2 * body and (candle["high"] - candle["close"]) / full_range >= 0.6:
|
||||
return True
|
||||
# Bearish candle with strong close
|
||||
if candle["close"] < candle["open"] and body / full_range >= 0.5:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _check_volume_declining(self, data, displacement_idx, idx):
|
||||
"""Check if volume is declining on the pullback to OB."""
|
||||
if idx <= displacement_idx + 2:
|
||||
return False
|
||||
displacement_vol = data["volume"].iloc[displacement_idx]
|
||||
pullback_vol = data["volume"].iloc[displacement_idx + 1:idx + 1].mean()
|
||||
return pullback_vol < 0.8 * displacement_vol
|
||||
|
||||
def _is_at_broken_sr(self, data, idx, ob_price, atr_val):
|
||||
"""Check if OB is at a level where prior S/R was broken (structural confluence)."""
|
||||
# Look for swing highs/lows near the OB price that were broken
|
||||
tolerance = 0.5 * atr_val
|
||||
lookback_start = max(0, idx - 200)
|
||||
|
||||
for i in range(lookback_start, idx - 20):
|
||||
bar = data.iloc[i]
|
||||
is_sh = bar.get("is_swing_high", False)
|
||||
is_sl_point = bar.get("is_swing_low", False)
|
||||
|
||||
if is_sh and abs(bar["high"] - ob_price) < tolerance:
|
||||
return True
|
||||
if is_sl_point and abs(bar["low"] - ob_price) < tolerance:
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 200:
|
||||
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
|
||||
|
||||
# HTF trend alignment
|
||||
if htf_row is None:
|
||||
return None
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if np.isnan(htf_ema200) or np.isnan(htf_close):
|
||||
return None
|
||||
|
||||
price = current["close"]
|
||||
|
||||
# Find order blocks
|
||||
order_blocks = self._find_order_blocks(data, idx, atr_val)
|
||||
|
||||
for ob in order_blocks:
|
||||
# Only trade OBs aligned with HTF trend
|
||||
if ob["direction"] == "LONG" and htf_close < htf_ema200:
|
||||
continue
|
||||
if ob["direction"] == "SHORT" and htf_close > htf_ema200:
|
||||
continue
|
||||
|
||||
# Check if price is retesting the OB zone
|
||||
# For LONG: price should be in or near the OB body zone (pullback down into it)
|
||||
if ob["direction"] == "LONG":
|
||||
if not (current["low"] <= ob["ob_body_high"] and price >= ob["ob_body_low"]):
|
||||
continue
|
||||
else:
|
||||
if not (current["high"] >= ob["ob_body_low"] and price <= ob["ob_body_high"]):
|
||||
continue
|
||||
|
||||
# Check OB is not too old (retest within window)
|
||||
bars_since = idx - ob["displacement_idx"]
|
||||
if bars_since > self.OB_RETEST_WINDOW or bars_since < 3:
|
||||
continue
|
||||
|
||||
# Rejection candle check
|
||||
if not self._is_rejection_candle(current, ob["direction"]):
|
||||
continue
|
||||
|
||||
# Confluence scoring (need 2 of 3)
|
||||
confluence_count = 0
|
||||
if ob["has_fvg"]:
|
||||
confluence_count += 1
|
||||
if self._check_volume_declining(data, ob["displacement_idx"], idx):
|
||||
confluence_count += 1
|
||||
ob_mid = (ob["ob_body_high"] + ob["ob_body_low"]) / 2
|
||||
if self._is_at_broken_sr(data, idx, ob_mid, atr_val):
|
||||
confluence_count += 1
|
||||
|
||||
if confluence_count < 2:
|
||||
continue
|
||||
|
||||
# Build exit levels using OB BODY (not wick) + buffer
|
||||
if ob["direction"] == "LONG":
|
||||
sl = ob["ob_body_low"] - self.SL_ATR_BUFFER * atr_val
|
||||
tp1 = price + self.TP1_ATR_MULT * atr_val
|
||||
tp2 = price + self.TP2_ATR_MULT * atr_val
|
||||
else:
|
||||
sl = ob["ob_body_high"] + self.SL_ATR_BUFFER * atr_val
|
||||
tp1 = price - self.TP1_ATR_MULT * atr_val
|
||||
tp2 = price - self.TP2_ATR_MULT * atr_val
|
||||
|
||||
return {
|
||||
"direction": ob["direction"],
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": min(confluence_count + 2, 5),
|
||||
"entry_pattern": f"order_block_retest_{ob['direction'].lower()}",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Strategy S9: London Session Gap (Asian Range Breakout).
|
||||
|
||||
Concept: Price breaks out of the Asian session range at London open, driven
|
||||
by institutional order flow from European/UK desks. Session-based volatility
|
||||
patterns are among the most well-documented phenomena in FX
|
||||
(Andersen & Bollerslev 1997, BIS data).
|
||||
|
||||
Entry conditions (ALL must be true):
|
||||
1. Asian range defined: 00:00-07:00 UTC high/low
|
||||
2. Asian range not too wide (< 1.5 ATR H1 and < pair-specific cap)
|
||||
3. Price breaks above Asian high (LONG) or below Asian low (SHORT)
|
||||
with a candle CLOSE beyond the level
|
||||
4. Volume > 3.0x Asian session average (first London candle almost always
|
||||
shows 2x, so 3x filters for meaningful surges)
|
||||
5. ADX > 20 (some trending context)
|
||||
6. Trade window: 07:00-10:00 UTC (London kill zone)
|
||||
|
||||
Exit:
|
||||
- SL: Opposite side of Asian range, capped at 1.5 ATR(H1) or pip limit
|
||||
- TP1: Asian range width as measured-move target (close 50%)
|
||||
- TP2: 2.0x Asian range width (close 50%)
|
||||
- Time exit: 17:00 UTC (captures full London-NY overlap)
|
||||
- Max hold: 40 bars (H1)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S9_London_Session(BaseStrategy):
|
||||
strategy_id = 9
|
||||
name = "S9_London_Session"
|
||||
|
||||
# Asian range window (UTC hours)
|
||||
ASIAN_START_HOUR = 0
|
||||
ASIAN_END_HOUR = 7
|
||||
|
||||
# Entry window (UTC hours)
|
||||
ENTRY_START_HOUR = 7
|
||||
ENTRY_END_HOUR = 10
|
||||
|
||||
# Exit time (UTC hour) — captures full London-NY overlap
|
||||
TIME_EXIT_HOUR = 17
|
||||
|
||||
# Volume threshold (relaxed from 3.0 for H1 — Asian H1 bars aren't dramatically
|
||||
# lower volume than London H1 bars the way M15 bars would be)
|
||||
VOLUME_MULT = 1.5
|
||||
|
||||
# Max Asian range (in pips) per pair category
|
||||
MAX_RANGE_PIPS = {
|
||||
"EUR_USD": 60, "GBP_USD": 80, "EUR_AUD": 80,
|
||||
"GBP_AUD": 100, "GBP_JPY": 100, "USD_JPY": 60,
|
||||
"EUR_CAD": 80, "GBP_CAD": 100, "EUR_GBP": 50,
|
||||
}
|
||||
|
||||
# SL cap in ATR (widened from 1.5 — was filtering out most days)
|
||||
SL_ATR_CAP = 2.5
|
||||
|
||||
MAX_BARS = 40
|
||||
|
||||
# Per-pair filter overrides (set via constructor with pair= and filtered=True)
|
||||
# Each key maps to a dict of: min_adx, rsi_neutral_skip, skip_friday,
|
||||
# entry_start_hour, tp1_mult, min_ema50_dist_pips
|
||||
PAIR_FILTERS = {
|
||||
"EUR_USD": {
|
||||
"tp1_mult": 1.5, # TP1 = 1.5x Asian range (was 1.0x)
|
||||
# NOTE: RSI filter and ADX hard gate tested but overfit — dropped
|
||||
},
|
||||
"GBP_AUD": {
|
||||
"min_adx": 25, # require ADX > 25
|
||||
"skip_friday": True, # drop Friday trades
|
||||
"min_ema50_dist_pips": 40, # require 40+ pips from EMA50
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, pair=None, filtered=False):
|
||||
super().__init__()
|
||||
self._asian_range_cache = {} # date -> (high, low, avg_vol)
|
||||
self._pair = pair
|
||||
self._filtered = filtered
|
||||
self._pair_cfg = {}
|
||||
if filtered and pair and pair in self.PAIR_FILTERS:
|
||||
self._pair_cfg = self.PAIR_FILTERS[pair]
|
||||
|
||||
def _get_pip_size(self, pair):
|
||||
if "JPY" in pair:
|
||||
return 0.01
|
||||
return 0.0001
|
||||
|
||||
def _compute_asian_range(self, data, idx):
|
||||
"""Compute Asian session range for the current day."""
|
||||
current_time = data.index[idx]
|
||||
current_date = current_time.date()
|
||||
|
||||
if current_date in self._asian_range_cache:
|
||||
return self._asian_range_cache[current_date]
|
||||
|
||||
# Find Asian session bars for today (00:00-07:00 UTC)
|
||||
asian_bars = []
|
||||
for i in range(max(0, idx - 50), idx + 1):
|
||||
bar_time = data.index[i]
|
||||
if bar_time.date() != current_date:
|
||||
continue
|
||||
bar_hour = bar_time.hour
|
||||
if self.ASIAN_START_HOUR <= bar_hour < self.ASIAN_END_HOUR:
|
||||
asian_bars.append(i)
|
||||
|
||||
if len(asian_bars) < 3:
|
||||
return None
|
||||
|
||||
asian_data = data.iloc[asian_bars]
|
||||
asian_high = asian_data["high"].max()
|
||||
asian_low = asian_data["low"].min()
|
||||
asian_avg_vol = asian_data["volume"].mean()
|
||||
|
||||
result = (asian_high, asian_low, asian_avg_vol)
|
||||
self._asian_range_cache[current_date] = result
|
||||
return result
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 200:
|
||||
return None
|
||||
|
||||
# Entry window (per-pair override for start hour)
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
start_hour = self._pair_cfg.get("entry_start_hour", self.ENTRY_START_HOUR)
|
||||
if hour < start_hour or hour >= self.ENTRY_END_HOUR:
|
||||
return None
|
||||
|
||||
# Friday filter (GBP_AUD: Friday position squaring kills breakouts)
|
||||
if self._pair_cfg.get("skip_friday", False):
|
||||
dow = current.name.dayofweek if hasattr(current.name, 'dayofweek') else 0
|
||||
if dow == 4: # Friday
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# Get HTF ATR for SL capping
|
||||
htf_atr = atr_val
|
||||
if htf_row is not None:
|
||||
htf_atr_val = htf_row.get("atr_14", np.nan)
|
||||
if not np.isnan(htf_atr_val) and htf_atr_val > 0:
|
||||
htf_atr = htf_atr_val
|
||||
|
||||
# Compute Asian range
|
||||
asian = self._compute_asian_range(data, idx)
|
||||
if asian is None:
|
||||
return None
|
||||
|
||||
asian_high, asian_low, asian_avg_vol = asian
|
||||
asian_range = asian_high - asian_low
|
||||
|
||||
if asian_range <= 0:
|
||||
return None
|
||||
|
||||
# Check Asian range not too wide
|
||||
pair = ""
|
||||
# Try to infer pair from strategy context; use default cap
|
||||
max_range_pips = 80 # default
|
||||
pip_size = self._get_pip_size("GBP_JPY" if atr_val > 0.005 else "EUR_USD")
|
||||
range_pips = asian_range / pip_size
|
||||
|
||||
# Cap: skip if Asian range > 1.5 ATR(H1)
|
||||
if asian_range > self.SL_ATR_CAP * htf_atr:
|
||||
return None
|
||||
|
||||
price = current["close"]
|
||||
|
||||
# ADX filter — hard gate when filtered, soft confluence otherwise
|
||||
adx_val = current.get("adx_14", 0)
|
||||
if np.isnan(adx_val):
|
||||
adx_val = 0
|
||||
min_adx = self._pair_cfg.get("min_adx", 0)
|
||||
if min_adx > 0 and adx_val < min_adx:
|
||||
return None
|
||||
adx_strong = adx_val > 20
|
||||
|
||||
# RSI neutral zone filter (EUR_USD: skip RSI 40-60 — no directional momentum)
|
||||
if self._pair_cfg.get("rsi_neutral_skip", False):
|
||||
rsi_val = current.get("rsi_14", 50)
|
||||
if not np.isnan(rsi_val) and 40 <= rsi_val <= 60:
|
||||
return None
|
||||
|
||||
# EMA50 distance filter (GBP_AUD: close-to-EMA trades underperform)
|
||||
min_ema_dist = self._pair_cfg.get("min_ema50_dist_pips", 0)
|
||||
if min_ema_dist > 0:
|
||||
ema50 = current.get("ema_50", np.nan)
|
||||
if not np.isnan(ema50) and ema50 > 0:
|
||||
pip_sz = self._get_pip_size(self._pair or "EUR_USD")
|
||||
dist_pips = abs(price - ema50) / pip_sz
|
||||
if dist_pips < min_ema_dist:
|
||||
return None
|
||||
|
||||
# Volume check: current volume > 1.5x Asian average
|
||||
vol = current.get("volume", 0)
|
||||
if asian_avg_vol <= 0 or vol < self.VOLUME_MULT * asian_avg_vol:
|
||||
return None
|
||||
|
||||
# Direction: breakout above or below Asian range
|
||||
direction = None
|
||||
if price > asian_high and current["close"] > asian_high:
|
||||
direction = "LONG"
|
||||
elif price < asian_low and current["close"] < asian_low:
|
||||
direction = "SHORT"
|
||||
|
||||
if direction is None:
|
||||
return None
|
||||
|
||||
# HTF trend alignment (soft: adds confluence but doesn't block)
|
||||
htf_aligned = False
|
||||
if htf_row is not None:
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if not np.isnan(htf_ema200) and not np.isnan(htf_close):
|
||||
if direction == "LONG" and htf_close > htf_ema200:
|
||||
htf_aligned = True
|
||||
elif direction == "SHORT" and htf_close < htf_ema200:
|
||||
htf_aligned = True
|
||||
|
||||
confluence = 3 + (1 if htf_aligned else 0) + (1 if adx_strong else 0)
|
||||
|
||||
# SL: opposite side of Asian range, capped
|
||||
tp1_mult = self._pair_cfg.get("tp1_mult", 1.0)
|
||||
|
||||
if direction == "LONG":
|
||||
raw_sl = asian_low
|
||||
sl_distance = price - raw_sl
|
||||
max_sl_distance = self.SL_ATR_CAP * htf_atr
|
||||
if sl_distance > max_sl_distance:
|
||||
raw_sl = price - max_sl_distance
|
||||
sl = raw_sl
|
||||
|
||||
tp1 = price + tp1_mult * asian_range
|
||||
tp2 = price + 2.0 * asian_range
|
||||
else:
|
||||
raw_sl = asian_high
|
||||
sl_distance = raw_sl - price
|
||||
max_sl_distance = self.SL_ATR_CAP * htf_atr
|
||||
if sl_distance > max_sl_distance:
|
||||
raw_sl = price + max_sl_distance
|
||||
sl = raw_sl
|
||||
|
||||
tp1 = price - tp1_mult * asian_range
|
||||
tp2 = price - 2.0 * asian_range
|
||||
|
||||
# Calculate max bars until 17:00 UTC time exit
|
||||
# On H1: roughly 17 - current_hour bars; on M15: (17-hour)*4
|
||||
# Use generic max_bars as fallback
|
||||
hours_remaining = self.TIME_EXIT_HOUR - hour
|
||||
if hours_remaining <= 0:
|
||||
return None
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": f"london_breakout_{direction.lower()}",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
Reference in New Issue
Block a user