| """ |
| wick_rules.py |
| Implements the pattern-detection logic from the wick-reading / liquidity / |
| stop-hunt guide: pin bars, engulfing wicks, equal-highs/lows sweeps |
| ("turtle soup"), Judas swings, and basic swing-structure (BOS/CHoCH). |
| |
| Every detector returns a dict (or None) with enough info for rating.py to |
| score it and strategies.py to build an order from it. Nothing here places |
| trades — this module only reads price. |
| """ |
| from dataclasses import dataclass |
| from typing import Optional |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| TOLERANCE_PCT = 0.0006 |
|
|
|
|
| @dataclass |
| class Signal: |
| pattern: str |
| side: str |
| index: int |
| entry_ref: float |
| sl_ref: float |
| reason: str |
| confluences: list |
| tp_ref: Optional[float] = None |
| |
| |
|
|
|
|
| def _wick_sizes(row): |
| body = abs(row["close"] - row["open"]) |
| upper = row["high"] - max(row["close"], row["open"]) |
| lower = min(row["close"], row["open"]) - row["low"] |
| return body, upper, lower |
|
|
|
|
| def find_swing_points(df: pd.DataFrame, lookback: int = 2): |
| """Simple fractal swing high/low detection for BOS/CHoCH context.""" |
| highs, lows = df["high"], df["low"] |
| swing_high = pd.Series(False, index=df.index) |
| swing_low = pd.Series(False, index=df.index) |
| for i in range(lookback, len(df) - lookback): |
| window_h = highs.iloc[i - lookback: i + lookback + 1] |
| window_l = lows.iloc[i - lookback: i + lookback + 1] |
| if highs.iloc[i] == window_h.max(): |
| swing_high.iloc[i] = True |
| if lows.iloc[i] == window_l.min(): |
| swing_low.iloc[i] = True |
| return swing_high, swing_low |
|
|
|
|
| def market_structure_bias(df: pd.DataFrame, lookback: int = 2) -> str: |
| """ |
| Very simplified BOS/CHoCH reader: compares the two most recent |
| confirmed swing highs and swing lows to infer trend direction. |
| Returns "bullish", "bearish", or "range". |
| """ |
| sh, sl = find_swing_points(df, lookback) |
| highs = df.loc[sh, "high"] |
| lows = df.loc[sl, "low"] |
| if len(highs) < 2 or len(lows) < 2: |
| return "range" |
| higher_highs = highs.iloc[-1] > highs.iloc[-2] |
| higher_lows = lows.iloc[-1] > lows.iloc[-2] |
| lower_highs = highs.iloc[-1] < highs.iloc[-2] |
| lower_lows = lows.iloc[-1] < lows.iloc[-2] |
| if higher_highs and higher_lows: |
| return "bullish" |
| if lower_highs and lower_lows: |
| return "bearish" |
| return "range" |
|
|
|
|
| def detect_pin_bar(df: pd.DataFrame, i: int, min_wick_body_ratio: float = 2.0) -> Optional[Signal]: |
| row = df.iloc[i] |
| body, upper, lower = _wick_sizes(row) |
| if body == 0: |
| body = 1e-9 |
|
|
| if lower >= min_wick_body_ratio * body and lower > upper: |
| close_pos = (row["close"] - row["low"]) / max(row["range"], 1e-9) |
| if close_pos > 0.6: |
| return Signal( |
| pattern="pin_bar", |
| side="buy", |
| index=i, |
| entry_ref=row["close"], |
| sl_ref=row["low"], |
| reason="Long lower wick rejection, close in upper third of range", |
| confluences=[], |
| ) |
| if upper >= min_wick_body_ratio * body and upper > lower: |
| close_pos = (row["high"] - row["close"]) / max(row["range"], 1e-9) |
| if close_pos > 0.6: |
| return Signal( |
| pattern="pin_bar", |
| side="sell", |
| index=i, |
| entry_ref=row["close"], |
| sl_ref=row["high"], |
| reason="Long upper wick rejection, close in lower third of range", |
| confluences=[], |
| ) |
| return None |
|
|
|
|
| def detect_engulfing_wick(df: pd.DataFrame, i: int, span: int = 3) -> Optional[Signal]: |
| if i < span: |
| return None |
| cur = df.iloc[i] |
| prior = df.iloc[i - span: i] |
| prior_high, prior_low = prior["high"].max(), prior["low"].min() |
|
|
| bullish = cur["close"] > cur["open"] and cur["low"] <= prior_low and cur["close"] > prior_high |
| bearish = cur["close"] < cur["open"] and cur["high"] >= prior_high and cur["close"] < prior_low |
|
|
| if bullish: |
| return Signal( |
| pattern="engulfing_wick", side="buy", index=i, entry_ref=cur["close"], |
| sl_ref=cur["low"], reason="Bullish engulfing sweep of prior range low", |
| confluences=[], |
| ) |
| if bearish: |
| return Signal( |
| pattern="engulfing_wick", side="sell", index=i, entry_ref=cur["close"], |
| sl_ref=cur["high"], reason="Bearish engulfing sweep of prior range high", |
| confluences=[], |
| ) |
| return None |
|
|
|
|
| def detect_liquidity_sweep(df: pd.DataFrame, i: int, lookback: int = 20) -> Optional[Signal]: |
| """ |
| 'Turtle soup' — price makes a marginal new high/low beyond a recent |
| swing extreme, then closes back inside the prior range. |
| """ |
| if i < lookback + 1: |
| return None |
| window = df.iloc[i - lookback: i] |
| cur = df.iloc[i] |
| prior_high, prior_low = window["high"].max(), window["low"].min() |
| tol = cur["close"] * TOLERANCE_PCT |
|
|
| swept_low = cur["low"] < prior_low - tol and cur["close"] > prior_low |
| swept_high = cur["high"] > prior_high + tol and cur["close"] < prior_high |
|
|
| if swept_low: |
| return Signal( |
| pattern="liquidity_sweep", side="buy", index=i, entry_ref=cur["close"], |
| sl_ref=cur["low"], reason=f"Swept prior {lookback}-bar low, closed back inside range", |
| confluences=[], |
| ) |
| if swept_high: |
| return Signal( |
| pattern="liquidity_sweep", side="sell", index=i, entry_ref=cur["close"], |
| sl_ref=cur["high"], reason=f"Swept prior {lookback}-bar high, closed back inside range", |
| confluences=[], |
| ) |
| return None |
|
|
|
|
| def detect_judas_swing(df: pd.DataFrame, i: int, session_open_hours=(7, 12)) -> Optional[Signal]: |
| """ |
| Session-open fakeout: aggressive move against the recent short-term |
| range within the first bars of a session (hours in candle "time", |
| which is broker/server time — adjust session_open_hours to match |
| your broker's UTC offset if needed). |
| """ |
| if i < 6: |
| return None |
| ts = df.iloc[i]["time"] |
| hour = ts.hour |
| if hour not in range(session_open_hours[0], session_open_hours[0] + 2) and \ |
| hour not in range(session_open_hours[1], session_open_hours[1] + 2): |
| return None |
|
|
| recent = df.iloc[max(0, i - 6): i] |
| cur = df.iloc[i] |
| range_high, range_low = recent["high"].max(), recent["low"].min() |
|
|
| if cur["low"] < range_low and cur["close"] > range_low: |
| return Signal( |
| pattern="judas_swing", side="buy", index=i, entry_ref=cur["close"], |
| sl_ref=cur["low"], reason="Session-open fakeout below range, reclaimed", |
| confluences=["session_timing"], |
| ) |
| if cur["high"] > range_high and cur["close"] < range_high: |
| return Signal( |
| pattern="judas_swing", side="sell", index=i, entry_ref=cur["close"], |
| sl_ref=cur["high"], reason="Session-open fakeout above range, rejected", |
| confluences=["session_timing"], |
| ) |
| return None |
|
|
|
|
| def detect_triple_top(df: pd.DataFrame, i: int, lookback: int = 50, tolerance_pct: float = 0.002) -> Optional[Signal]: |
| """ |
| Detects a triple top pattern: |
| - Three swing highs at approximately the same level |
| - A neckline (support level) connecting the lows between the highs |
| - Price breaks below the neckline for confirmation |
| |
| Returns a Signal for the neckline break, looking for bearish engulfing |
| on retest for entry. |
| """ |
| if i < lookback + 10: |
| return None |
| |
| |
| window = df.iloc[i - lookback: i + 1] |
| |
| |
| sh, _ = find_swing_points(window, lookback=2) |
| swing_highs = window.loc[sh, "high"] |
| |
| if len(swing_highs) < 3: |
| return None |
| |
| |
| last_three_highs = swing_highs.iloc[-3:] |
| |
| |
| high_level = last_three_highs.iloc[0] |
| tolerance = high_level * tolerance_pct |
| |
| all_similar = all( |
| abs(h - high_level) <= tolerance |
| for h in last_three_highs |
| ) |
| |
| if not all_similar: |
| return None |
| |
| |
| |
| swing_high_indices = swing_highs.index[-3:].tolist() |
| |
| |
| neckline_lows = [] |
| for j in range(len(swing_high_indices) - 1): |
| start_idx = swing_high_indices[j] |
| end_idx = swing_high_indices[j + 1] |
| between_lows = window.loc[start_idx:end_idx, "low"] |
| neckline_lows.append(between_lows.min()) |
| |
| |
| last_sh_idx = swing_high_indices[-1] |
| if last_sh_idx < len(window) - 1: |
| after_lows = window.loc[last_sh_idx:i, "low"] |
| neckline_lows.append(after_lows.min()) |
| |
| if not neckline_lows: |
| return None |
| |
| neckline = min(neckline_lows) |
| |
| |
| cur = df.iloc[i] |
| if cur["close"] < neckline: |
| return Signal( |
| pattern="triple_top", |
| side="sell", |
| index=i, |
| entry_ref=cur["close"], |
| sl_ref=high_level, |
| reason=f"Triple top neckline broken at {neckline:.5f}, three highs near {high_level:.5f}", |
| confluences=["triple_top_pattern", "neckline_break"], |
| ) |
| |
| return None |
|
|
|
|
| DETECTORS = [detect_pin_bar, detect_engulfing_wick, detect_liquidity_sweep, detect_judas_swing, detect_triple_top] |
|
|
|
|
| def scan_latest(df: pd.DataFrame) -> list: |
| """Runs every detector against the most recently CLOSED candle (index -2, |
| since the last row from MT5 is usually the still-forming current bar).""" |
| if len(df) < 30: |
| return [] |
| i = len(df) - 2 |
| signals = [] |
| for detector in DETECTORS: |
| sig = detector(df, i) |
| if sig: |
| signals.append(sig) |
| return signals |
|
|