Spaces:
Runtime error
Runtime error
File size: 9,088 Bytes
6ca1e7a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | """
Order Block + Fair Value Gap strategy.
Definitions
-----------
Fair Value Gap (3-candle imbalance):
- Bullish FVG: candle[i-1].high < candle[i+1].low
=> gap zone = (candle[i-1].high, candle[i+1].low)
- Bearish FVG: candle[i-1].low > candle[i+1].high
=> gap zone = (candle[i+1].high, candle[i-1].low)
Order Block (anchor candle):
- Bullish OB = last bearish candle (close < open) immediately preceding
the bullish impulse that created the FVG.
- Bearish OB = last bullish candle (close > open) immediately preceding
the bearish impulse.
- OB zone = (low, high) of that candle.
Signal logic
------------
When current price retraces into an unmitigated OB zone of the same direction
and the FVG anchoring it is still (at least partially) unfilled, emit a signal:
Bullish OB tag -> BUY
Bearish OB tag -> SELL
Risk plan
---------
entry = midpoint of OB zone
SL = OB extreme on the protected side - 1 * ATR(14) buffer
TP = nearest swing liquidity in trade direction, capped at 3R / floored at 1.5R
"""
from __future__ import annotations
from dataclasses import dataclass, asdict
from typing import Optional
import numpy as np
import pandas as pd
from .schemas import Candle, FVG, OrderBlock, StrategySignal
# ----------------------------- helpers --------------------------------------
def candles_to_df(candles: list[Candle]) -> pd.DataFrame:
return pd.DataFrame([c.model_dump() for c in candles])
def atr(df: pd.DataFrame, period: int = 14) -> float:
if len(df) < period + 1:
return float((df["high"] - df["low"]).mean() or 0.0)
h, l, c = df["high"].values, df["low"].values, df["close"].values
tr = np.maximum.reduce([
h[1:] - l[1:],
np.abs(h[1:] - c[:-1]),
np.abs(l[1:] - c[:-1]),
])
return float(pd.Series(tr).rolling(period).mean().iloc[-1])
def ema(series: pd.Series, period: int) -> float:
return float(series.ewm(span=period, adjust=False).mean().iloc[-1])
def rsi(series: pd.Series, period: int = 14) -> float:
delta = series.diff()
up = delta.clip(lower=0).rolling(period).mean()
down = (-delta.clip(upper=0)).rolling(period).mean()
rs = up / down.replace(0, np.nan)
val = 100 - (100 / (1 + rs.iloc[-1]))
return float(val) if not np.isnan(val) else 50.0
def swing_high(df: pd.DataFrame, lookback: int = 50) -> float:
return float(df["high"].tail(lookback).max())
def swing_low(df: pd.DataFrame, lookback: int = 50) -> float:
return float(df["low"].tail(lookback).min())
# ----------------------------- detectors ------------------------------------
def detect_fvgs(df: pd.DataFrame, max_age: int = 100) -> list[FVG]:
fvgs: list[FVG] = []
n = len(df)
start = max(1, n - max_age - 1)
end = n - 1 # need i+1 to exist
h = df["high"].values
l = df["low"].values
for i in range(start, end):
# bullish: gap between prev high and next low
if h[i - 1] < l[i + 1]:
fvgs.append(FVG(kind="bullish", bottom=float(h[i - 1]),
top=float(l[i + 1]), index=i))
# bearish: gap between next high and prev low
elif l[i - 1] > h[i + 1]:
fvgs.append(FVG(kind="bearish", bottom=float(h[i + 1]),
top=float(l[i - 1]), index=i))
# mark filled if price has since traded through
last_close = float(df["close"].iloc[-1])
last_high = float(df["high"].max())
last_low = float(df["low"].min())
for f in fvgs:
post = df.iloc[f.index + 1:]
if f.kind == "bullish":
f.filled = bool((post["low"] <= f.bottom).any())
else:
f.filled = bool((post["high"] >= f.top).any())
return fvgs
def detect_order_blocks(df: pd.DataFrame, fvgs: list[FVG]) -> list[OrderBlock]:
obs: list[OrderBlock] = []
o = df["open"].values
c = df["close"].values
h = df["high"].values
l = df["low"].values
for f in fvgs:
# search backwards from FVG anchor for last opposite-color candle
ob_kind = "bullish" if f.kind == "bullish" else "bearish"
# bullish OB = last bearish (red) candle before bullish impulse
# bearish OB = last bullish (green) candle before bearish impulse
want_red = (ob_kind == "bullish")
idx = None
for j in range(f.index - 1, max(f.index - 10, -1), -1):
is_red = c[j] < o[j]
if want_red and is_red:
idx = j
break
if (not want_red) and (c[j] > o[j]):
idx = j
break
if idx is None:
continue
ob = OrderBlock(
kind=ob_kind,
top=float(h[idx]),
bottom=float(l[idx]),
index=idx,
fvg_index=f.index,
)
# mitigated if price has revisited the zone after creation
post = df.iloc[idx + 1:]
if ob.kind == "bullish":
ob.mitigated = bool((post["low"] <= ob.top).any() and
(post["low"] <= ob.bottom).any())
else:
ob.mitigated = bool((post["high"] >= ob.bottom).any() and
(post["high"] >= ob.top).any())
obs.append(ob)
return obs
# ----------------------------- signal generator -----------------------------
def generate_signal(symbol: str, timeframe: str,
candles: list[Candle]) -> StrategySignal:
if len(candles) < 30:
return StrategySignal(
symbol=symbol, timeframe=timeframe, decision="WAIT",
confidence=0.0, price=candles[-1].close if candles else 0.0,
rationale="Insufficient candle history (need >= 30).",
)
df = candles_to_df(candles)
price = float(df["close"].iloc[-1])
a = atr(df)
rsi_v = rsi(df["close"])
ema20 = ema(df["close"], 20)
ema50 = ema(df["close"], 50)
trend = "up" if ema20 > ema50 else "down"
fvgs = detect_fvgs(df)
obs = detect_order_blocks(df, fvgs)
# find the most recent valid (unmitigated) OB whose FVG is unfilled
candidate: Optional[OrderBlock] = None
paired_fvg: Optional[FVG] = None
for ob in reversed(obs):
f = next((x for x in fvgs if x.index == ob.fvg_index), None)
if not f or f.filled or ob.mitigated:
continue
# require current price near OB (within 2 ATR)
dist = min(abs(price - ob.top), abs(price - ob.bottom))
if dist > 2 * a and not (ob.bottom <= price <= ob.top):
continue
candidate, paired_fvg = ob, f
break
indicators = {
"ema20": ema20, "ema50": ema50, "rsi14": rsi_v,
"atr14": a, "trend": trend,
"swing_high": swing_high(df), "swing_low": swing_low(df),
"fvg_count": len(fvgs), "ob_count": len(obs),
}
if not candidate or not paired_fvg:
return StrategySignal(
symbol=symbol, timeframe=timeframe, decision="WAIT",
confidence=0.25, price=price,
rationale="No unmitigated OB / unfilled FVG confluence near price.",
indicators=indicators,
)
# build trade plan
if candidate.kind == "bullish":
entry = (candidate.top + candidate.bottom) / 2
sl = candidate.bottom - a
tp_liquidity = indicators["swing_high"]
r = entry - sl
tp = max(min(tp_liquidity, entry + 3 * r), entry + 1.5 * r)
decision = "BUY"
else:
entry = (candidate.top + candidate.bottom) / 2
sl = candidate.top + a
tp_liquidity = indicators["swing_low"]
r = sl - entry
tp = min(max(tp_liquidity, entry - 3 * r), entry - 1.5 * r)
decision = "SELL"
# confidence model: trend alignment + RSI sanity + freshness
trend_align = (decision == "BUY" and trend == "up") or \
(decision == "SELL" and trend == "down")
rsi_ok = (decision == "BUY" and rsi_v < 65) or \
(decision == "SELL" and rsi_v > 35)
freshness = max(0.0, 1.0 - (len(df) - 1 - candidate.index) / 50)
confidence = float(np.clip(
0.40 + 0.20 * trend_align + 0.15 * rsi_ok + 0.25 * freshness, 0, 0.95
))
rationale = (
f"{candidate.kind.title()} OB at [{candidate.bottom:.5f}, "
f"{candidate.top:.5f}] anchors an unfilled {paired_fvg.kind} FVG. "
f"Price {price:.5f} is reacting to the zone. Trend {trend.upper()} "
f"(EMA20 {ema20:.5f} vs EMA50 {ema50:.5f}), RSI14 {rsi_v:.1f}, "
f"ATR14 {a:.5f}. Plan: entry {entry:.5f}, SL {sl:.5f}, TP {tp:.5f}."
)
return StrategySignal(
symbol=symbol, timeframe=timeframe, decision=decision,
confidence=confidence, price=price,
entry=entry, sl=sl, tp=tp,
ob=candidate, fvg=paired_fvg, rationale=rationale,
indicators=indicators,
)
|