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:
Brent Neale
2026-02-19 13:56:03 +10:00
co-authored by Claude Opus 4.6
parent 70a216d695
commit 39a6536284
83 changed files with 7737 additions and 94 deletions
+12
View File
@@ -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
}
+275
View File
@@ -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
+268
View File
@@ -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
+270
View File
@@ -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,
}