wickbot / wick_rules.py
Joedroid's picture
deploy: update wickbot codebase (part 7)
4550bff verified
Raw
History Blame Contribute Delete
10.5 kB
"""
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 # ~6 pips on a 4-decimal pair; used for "equal highs/lows"
@dataclass
class Signal:
pattern: str
side: str # "buy" or "sell"
index: int
entry_ref: float
sl_ref: float # suggested stop reference (beyond the wick extreme)
reason: str
confluences: list
tp_ref: Optional[float] = None # explicit TP for strategies with a fixed target
# (e.g. a Fibonacci retracement level) rather than
# the default R-multiple TP risk_manager computes
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: # closed in top part of range -> bullish rejection
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
# Get the lookback window
window = df.iloc[i - lookback: i + 1]
# Find swing highs in the window
sh, _ = find_swing_points(window, lookback=2)
swing_highs = window.loc[sh, "high"]
if len(swing_highs) < 3:
return None
# Get the last 3 swing highs
last_three_highs = swing_highs.iloc[-3:]
# Check if they're approximately equal (within tolerance)
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
# Find the neckline (support level) - the lows between the three highs
# Get indices of the three swing highs
swing_high_indices = swing_highs.index[-3:].tolist()
# Find the lowest low between each pair of swing highs
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())
# Also get the low after the last swing high
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)
# Check for neckline break (price closes below neckline)
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