Spaces:
Runtime error
Runtime error
| """ | |
| 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, | |
| ) | |