Spaces:
Running
Running
File size: 18,763 Bytes
aa79b15 6db631e aa79b15 6db631e aa79b15 2c8a3a1 | 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 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 | """analysis/timeframes.py β HTF/MTF/LTF/WTF timeframe analysis functions."""
import numpy as np
import pandas as pd
from config import (RSI_OB, RSI_OS, RSI_MA_PERIOD, ADX_MIN, MTF_MIN_SCORE)
from analysis.structure import detect_swings, find_sr, analyse_structure, analyse_volume, analyse_sr, analyse_momentum
from data.oanda import pip, digs
def analyse_htf(df):
"""
D1 bias using EMA20 / EMA50 / SMA200 stack.
Bullish: EMA20 > EMA50 > SMA200 AND price above EMA20
Bearish: EMA20 < EMA50 < SMA200 AND price below EMA20
Neutral: mixed / conflicting
3-point scoring:
+1 EMA20 vs EMA50 direction
+1 Full EMA stack aligned (EMA20/50/SMA200)
+1 Price on correct side of EMA20
"""
row = df.iloc[-1]
ema_bull = row["ma_fast"] > row["ma_slow"] # EMA20 > EMA50
ema_bear = row["ma_fast"] < row["ma_slow"]
# Full stack check (EMA20 > EMA50 > SMA200)
has_trend = "ma_trend" in df.columns and not pd.isna(row.get("ma_trend", float("nan")))
stack_bull = bool(row.get("ema_stack_bull", False)) if has_trend else ema_bull
stack_bear = bool(row.get("ema_stack_bear", False)) if has_trend else ema_bear
# Price vs EMA20
price_bull = row["close"] > row["ma_fast"]
price_bear = row["close"] < row["ma_fast"]
# SMA200 slope (compare to 5 bars ago)
if has_trend:
sma200_now = row["ma_trend"]
sma200_prev = df["ma_trend"].iloc[-6]
sma_up = sma200_now > sma200_prev
sma_down = sma200_now < sma200_prev
sma_label = "rising" if sma_up else "falling" if sma_down else "flat"
sma_val = f"{sma200_now:.5f}"
else:
sma_up = sma_down = False; sma_label = "N/A"; sma_val = "N/A"
bull = sum([ema_bull, stack_bull, price_bull])
bear = sum([ema_bear, stack_bear, price_bear])
if bull >= 2 and bull > bear: bias = 1
elif bear >= 2 and bear > bull: bias = -1
else: bias = 0
return {
"ema_fast": float(row["ema_fast"]),
"ema_mid": float(row["ema_mid"]),
"ema_slow": float(row["ema_slow"]),
"bias": bias,
"rsi": float(row["rsi"]),
"atr": float(row["atr"]),
"sr_levels": find_sr(df, instrument, n=8),
}
def detect_rsi_ma_bounce(df, bias):
"""
RSI SMA-9 Bounce β 4H bonus confluence check.
Bullish bounce (bias=1):
RSI dipped below its SMA9 recently (last 5 bars)
RSI is now back above SMA9 and rising
β Momentum pulled back to average and is resuming upward
Bearish bounce (bias=-1):
RSI rallied above its SMA9 recently (last 5 bars)
RSI is now back below SMA9 and falling
β Momentum bounced to average and is resuming downward
This is a bonus +0.5 β not a required check.
Most valuable in trending markets (ADX > 20).
Returns: (detected: bool, note: str, rsi_val: float, rsi_ma_val: float)
"""
if "rsi_ma" not in df.columns or len(df) < RSI_MA_PERIOD + 3:
return False, "RSI MA not available", 0, 0
row = df.iloc[-1]
prev = df.iloc[-2]
recent = df.iloc[-6:-1] # last 5 bars before current
rsi_now = row["rsi"]
rsi_ma_now = row["rsi_ma"]
rsi_prev = prev["rsi"]
if pd.isna(rsi_ma_now): return False, "RSI MA not ready", rsi_now, 0
if bias == 1: # Bullish β RSI bouncing up off SMA9
# Recently dipped below SMA9
dipped_below = any(recent["rsi"] < recent["rsi_ma"])
# Now back above SMA9
back_above = rsi_now > rsi_ma_now
# RSI rising
rsi_rising = rsi_now > rsi_prev
# SMA9 itself is rising or flat (not falling steeply)
ma_direction = rsi_ma_now >= df["rsi_ma"].iloc[-4]
detected = dipped_below and back_above and rsi_rising
note = (f"RSI({rsi_now:.1f}) bounced off SMA9({rsi_ma_now:.1f}) β "
f"{'β
' if detected else 'β'} "
f"| dip:{'β
' if dipped_below else 'β'} "
f"above:{'β
' if back_above else 'β'} "
f"rising:{'β
' if rsi_rising else 'β'}")
else: # Bearish β RSI bouncing down off SMA9
# Recently rallied above SMA9
rallied_above = any(recent["rsi"] > recent["rsi_ma"])
# Now back below SMA9
back_below = rsi_now < rsi_ma_now
# RSI falling
rsi_falling = rsi_now < rsi_prev
# SMA9 itself is falling or flat
ma_direction = rsi_ma_now <= df["rsi_ma"].iloc[-4]
detected = rallied_above and back_below and rsi_falling
note = (f"RSI({rsi_now:.1f}) rejected at SMA9({rsi_ma_now:.1f}) β "
f"{'β
' if detected else 'β'} "
f"| rally:{'β
' if rallied_above else 'β'} "
f"below:{'β
' if back_below else 'β'} "
f"falling:{'β
' if rsi_falling else 'β'}")
return detected, note, round(rsi_now, 1), round(rsi_ma_now, 1)
def find_weekly_sr(df_wtf, instrument, n=6):
"""
Weekly S&R levels β highest quality levels because they represent
multi-week institutional accumulation/distribution zones.
Uses swing highs/lows from W1 chart with larger clustering zone.
"""
if df_wtf.empty or len(df_wtf) < 5:
return []
df_sw = detect_swings(df_wtf, n=2)
highs = df_sw["swing_high"].dropna()
lows = df_sw["swing_low"].dropna()
raw = pd.concat([highs, lows]).sort_values().values
if not len(raw): return []
# Weekly levels cluster in a wider zone (20 pips)
zone = 20 * pip(instrument)
clusters = []; cur = [raw[0]]
for p_val in raw[1:]:
if p_val - cur[-1] <= zone: cur.append(p_val)
else: clusters.append(cur); cur = [p_val]
clusters.append(cur)
clusters.sort(key=len, reverse=True)
return [round(np.mean(c), digs(instrument)) for c in clusters[:n]]
def check_round_number(price, instrument, pips=20):
"""
Round number proximity check.
Round numbers (1.3000, 150.00, 2.0000) act as magnets β
price gravitates toward them and often reacts strongly.
Checks if price is within `pips` of a round number.
Returns (near: bool, nearest_round: float, distance_pips: float)
"""
p = pip(instrument)
d = digs(instrument)
# Determine round number step based on instrument
if "_JPY" in instrument:
# JPY pairs: round at 0.5 and whole numbers (e.g. 150.00, 150.50)
step = 0.5
elif instrument == "XAU_USD":
# Gold: round at 10s (1800, 1810, 1900)
step = 10.0
else:
# All others: round at 0.0100 and 0.0050 (e.g. 1.3000, 1.3050)
step = 0.01
# Find nearest round number
nearest = round(round(price / step) * step, d)
distance_pips = abs(price - nearest) / p
near = distance_pips <= pips
return near, nearest, round(distance_pips, 1)
def check_multitf_sr(df_htf, df_mtf, instrument, price):
"""
Multi-timeframe S&R confluence.
A level that appears on BOTH D1 and 4H is far stronger than one
that only shows on 4H alone.
Returns:
confluence: bool β a level is confirmed on both TFs
level: float β the confluent level
note: str β description
"""
d1_levels = find_sr(df_htf, instrument, n=8)
h4_levels = find_sr(df_mtf, instrument, n=8)
zone = 15 * pip(instrument) # 15 pip matching zone between TFs
d = digs(instrument)
confluent = []
for d1_l in d1_levels:
for h4_l in h4_levels:
if abs(d1_l - h4_l) <= zone:
avg = (d1_l + h4_l) / 2
dist = abs(price - avg) / pip(instrument)
confluent.append((avg, dist))
if not confluent:
return False, None, "No D1+4H confluent levels"
# Find nearest confluent level to current price
confluent.sort(key=lambda x: x[1])
best_level, best_dist = confluent[0]
near = best_dist <= 30 # within 30 pips
return near, round(best_level, d), f"D1+4H level @ {best_level:.{d}f} ({best_dist:.0f} pips away)"
def analyse_wtf(df):
"""
Weekly (W1) macro bias β top of the stack.
Uses same EMA20/EMA50/SMA200 logic as D1 but on weekly candles.
Weekly bias is the most important layer β sets the macro direction.
Scoring (3 points):
+1 EMA20 vs EMA50 direction
+1 Full stack aligned (EMA20/50/SMA200)
+1 Price on correct side of weekly EMA20
Returns bias: 1 (bullish), -1 (bearish), 0 (neutral)
Plus extra context: key weekly levels, trend strength
"""
if df.empty or len(df) < 10:
return {"bias": 0, "bull_pts": 0, "bear_pts": 0,
"ema20": None, "ema50": None, "sma200": "N/A",
"sma_slope": "N/A", "rsi": 50.0, "adx": 0.0,
"stack_bull": False, "stack_bear": False,
"note": "Insufficient weekly data"}
row = df.iloc[-1]
ema_bull = row["ma_fast"] > row["ma_slow"]
ema_bear = row["ma_fast"] < row["ma_slow"]
has_trend = "ma_trend" in df.columns and not pd.isna(row.get("ma_trend", float("nan")))
stack_bull = bool(row.get("ema_stack_bull", False)) if has_trend else ema_bull
stack_bear = bool(row.get("ema_stack_bear", False)) if has_trend else ema_bear
price_bull = row["close"] > row["ma_fast"]
price_bear = row["close"] < row["ma_fast"]
if has_trend:
sma200_now = row["ma_trend"]
sma200_prev = df["ma_trend"].iloc[-4] # 4 weeks ago
sma_label = "rising" if sma200_now > sma200_prev else "falling" if sma200_now < sma200_prev else "flat"
sma_val = f"{sma200_now:.5f}"
else:
sma_label = "N/A"; sma_val = "N/A"; sma200_now = None
bull = sum([ema_bull, stack_bull, price_bull])
bear = sum([ema_bear, stack_bear, price_bear])
if bull >= 2 and bull > bear: bias = 1
elif bear >= 2 and bear > bull: bias = -1
else: bias = 0
# Weekly key levels β swing highs/lows as major S/R
w_sw = detect_swings(df, n=2)
recent_w = w_sw.iloc[-20:]
w_highs = recent_w["swing_high"].dropna().values[-3:] if len(recent_w["swing_high"].dropna()) >= 1 else []
w_lows = recent_w["swing_low"].dropna().values[-3:] if len(recent_w["swing_low"].dropna()) >= 1 else []
return {
"bias": bias,
"bull_pts": bull,
"bear_pts": bear,
"ema20": round(row["ma_fast"], 5),
"ema50": round(row["ma_slow"], 5),
"sma200": sma_val,
"sma200_raw": sma200_now,
"sma_slope": sma_label,
"rsi": round(row["rsi"], 1),
"adx": round(row["adx"], 1),
"stack_bull": stack_bull,
"stack_bear": stack_bear,
"zone": row["price_zone"],
"w_highs": [round(h, 5) for h in w_highs],
"w_lows": [round(l, 5) for l in w_lows],
"w_sr": [],
"note": "",
}
def analyse_mtf(df,htf_bias,instrument):
if htf_bias==0: return {"confirmed":False,"score":0,"max":7,"checks":{}}
row=df.iloc[-1]; checks={}; score=0
def chk(key,ok,note):
nonlocal score; checks[key]=(ok,note); score+=int(ok)
# EMA20 vs EMA50 direction
ema_dir_ok = row["ma_fast"]>row["ma_slow"] if htf_bias==1 else row["ma_fast"]<row["ma_slow"]
chk("1_ema20_50", ema_dir_ok, f"EMA20({'>' if htf_bias==1 else '<'})EMA50: {row['ma_fast']:.5f} vs {row['ma_slow']:.5f}")
# Full EMA stack: EMA20/EMA50/SMA200 all aligned
has_stack = "ema_stack_bull" in df.columns
if has_stack:
stack_ok = bool(row.get("ema_stack_bull",False)) if htf_bias==1 else bool(row.get("ema_stack_bear",False))
sma200 = f"{row['ma_trend']:.5f}" if "ma_trend" in df.columns else "N/A"
chk("1b_ema_stack", stack_ok, f"EMA20/50/SMA200 stack {'β
' if stack_ok else 'β'} | SMA200:{sma200}")
zone_ok=row["price_zone"] in (("above_both","between_pb_up") if htf_bias==1 else ("below_both","between_pb_dn"))
zone_labels = {
"above_both": "above both EMAs β strong trend β
",
"between_pb_up": "between EMAs β healthy pullback β
",
"below_both": "below both EMAs β strong downtrend β
",
"between_pb_dn": "between EMAs β bearish pullback β
",
"mixed": "mixed β price not clearly positioned β",
}
zone_note = zone_labels.get(row["price_zone"], row["price_zone"])
chk("2_zone", zone_ok, f"Zone: {row['price_zone']} β {zone_note}")
rsi_ok=row["rsi"]<RSI_OB if htf_bias==1 else row["rsi"]>RSI_OS
chk("3_rsi",rsi_ok,f"RSI:{row['rsi']:.1f}")
ok,note=analyse_structure(df,htf_bias); chk("4_struct",ok,note)
ok,note=analyse_volume(df,htf_bias); chk("5_vol",ok,note)
ok,note=analyse_sr(df,htf_bias,instrument); chk("6_sr",ok,note)
ok,note=analyse_momentum(df,htf_bias); chk("7_mom",ok,note)
mtf_max = 8 if any("ema_stack" in k for k in checks) else 7
# ββ RSI MA Bounce bonus (+0.5, not counted in max score) ββ
rsi_bounce_ok, rsi_bounce_note, rsi_val, rsi_ma_val = detect_rsi_ma_bounce(df, htf_bias)
rsi_bonus = 0.5 if rsi_bounce_ok else 0.0
return {
"zone": row["price_zone"],
"sr_levels": find_sr(df, instrument, n=8),
}
def analyse_ltf(df, htf_bias):
"""
LTF (1H or 30M) entry trigger. Two conditions must pass:
1. EMA cross β EMA20 crossed EMA50 in bias direction within last 5 bars
OR EMA20 is currently on correct side (crossed more than 5 bars ago
but still valid β strong trend continuation)
2. RSI confirmation β two modes:
PULLBACK mode (normal): RSI reversed from extreme
β RSI dipped near OS (sell: spiked near OB) and now reversing
TREND mode (strong trend, ADX > 30 on LTF):
β RSI just needs to be on correct side and falling/rising
β Used when price is in a straight trend without RSI spikes
β e.g. NZD/USD strong downtrend: RSI at 30 and falling = valid SELL
"""
if htf_bias == 0:
return {"triggered":False,"signal":0,"cross_ok":False,"rsi_ok":False,
"rsi_mode":"none"}
row = df.iloc[-1]
prev = df.iloc[-2]
recent = df.iloc[-10:] # look back 10 bars for RSI extreme
# ββ EMA Cross check ββββββββββββββββββββββββββββββββββ
# Primary: crossover bar in last 5 bars
# Secondary: EMA20 already on correct side + crossed in last 10 bars
# Tertiary: EMA20 aligned + strong trend (ADX > 25) β cross happened earlier but trend intact
if htf_bias == 1:
cross_recent = any(df["cross"].iloc[-5:] == 1)
cross_aligned = row["ma_fast"] > row["ma_slow"]
adx_strong_ltf = row["adx"] > 25 # require momentum for trend entries
cross_extended = any(df["cross"].iloc[-10:] == 1) and adx_strong_ltf
# cross_trend: EMA aligned + ADX > 25 β prevents false entries in ranging markets
cross_trend = cross_aligned and adx_strong_ltf and row["price_zone"] in ("above_both", "between_pb_up")
else:
cross_recent = any(df["cross"].iloc[-5:] == -1)
cross_aligned = row["ma_fast"] < row["ma_slow"]
adx_strong_ltf = row["adx"] > 25
cross_extended = any(df["cross"].iloc[-10:] == -1) and adx_strong_ltf
# cross_trend: EMA aligned + ADX > 25
cross_trend = cross_aligned and adx_strong_ltf and row["price_zone"] in ("below_both", "between_pb_dn")
cross_ok = cross_recent or cross_extended or cross_trend
# ββ RSI check β two modes ββββββββββββββββββββββββββββ
adx_strong = row["adx"] > 30 # strong trend mode
if htf_bias == 1:
# PULLBACK mode: RSI dipped near oversold then recovering
rsi_pullback = ((recent["rsi"] < RSI_OS + 10).any()
and row["rsi"] > prev["rsi"]
and row["rsi"] < RSI_OB)
# TREND mode: RSI below 55 and rising (strong bull, ADX > 30)
rsi_trend = adx_strong and row["rsi"] < 55 and row["rsi"] > prev["rsi"]
# MOMENTUM mode: RSI above 55 and rising regardless of ADX
# β Bullish counterpart: RSI in bullish territory and rising = valid
rsi_momentum = (row["rsi"] > 55
and row["rsi"] > prev["rsi"]
and row["rsi"] < RSI_OB)
rsi_ok = rsi_pullback or rsi_trend or rsi_momentum
rsi_mode = ("trend" if rsi_trend and not rsi_pullback
else "momentum" if rsi_momentum and not rsi_pullback
else "pullback")
else:
# PULLBACK mode: RSI spiked near overbought then falling
rsi_pullback = ((recent["rsi"] > RSI_OB - 10).any()
and row["rsi"] < prev["rsi"]
and row["rsi"] > RSI_OS)
# TREND mode: RSI above 50 and falling (strong bear, ADX > 30)
rsi_trend = adx_strong and row["rsi"] > 45 and row["rsi"] < prev["rsi"]
# CONTINUATION mode: RSI deeply oversold + falling (strong bear, ADX > 30)
rsi_oversold = adx_strong and row["rsi"] < RSI_OS + 5
# MOMENTUM mode: RSI below 45 and falling regardless of ADX
# β NZD/USD: RSI 38.3 falling, W1+D1 bearish, EMA crossed β valid signal
# β No spike needed when RSI is already in bearish territory and moving down
# β D1 ADX must be strong (>30) to compensate for low H1 ADX
rsi_momentum = (row["rsi"] < 45
and row["rsi"] < prev["rsi"]
and row["rsi"] > RSI_OS) # not yet oversold (room to run)
rsi_ok = rsi_pullback or rsi_trend or rsi_oversold or rsi_momentum
rsi_mode = ("oversold_cont" if rsi_oversold and not rsi_pullback
else "trend" if rsi_trend and not rsi_pullback
else "momentum" if rsi_momentum and not rsi_pullback
else "pullback")
triggered = cross_ok and rsi_ok
return {
"triggered": triggered,
"signal": htf_bias if triggered else 0,
"cross_ok": cross_ok,
"rsi_ok": rsi_ok,
"rsi": round(row["rsi"], 1),
"zone": row["price_zone"],
"rsi_mode": rsi_mode,
"adx": round(row["adx"], 1),
}
# Compatibility wrappers for newer scorer imports
def analyse_wtf_v2(df):
return analyse_wtf(df)
def analyse_htf_v2(df):
return analyse_htf(df)
def analyse_mtf_v2(df, htf_bias, instrument):
return analyse_mtf(df, htf_bias, instrument)
def analyse_ltf_v2(df, htf_bias):
return analyse_ltf(df, htf_bias)
|