Spaces:
Running
Running
File size: 14,483 Bytes
cb145d1 | 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 | """Multi-timeframe confluence analyzer.
Synthesises all Price Action evidence into a single A+/A/B/C/D grade.
Scoring weights:
Structure 35% — EMA alignment, trend direction across timeframes
Patterns 25% — Candlestick + chart patterns (from patterns.py + chart_patterns.py)
OB/FVG 20% — Order blocks and fair value gaps (from order_blocks.py)
Momentum 20% — RSI position, funding rate, volume
Grade mapping (0–100 composite):
A+ 85–100 All evidence aligned — highest conviction entry
A 70–84 Strong alignment — good entry
B 55–69 Moderate — proceed with caution
C 40–54 Mixed signals — wait for clarity
D 0–39 Conflicting — avoid
All returns are JSON-serialisable.
"""
from __future__ import annotations
# ─────────────────────────────────────────────────────────────────────────────
# Grade helpers
# ─────────────────────────────────────────────────────────────────────────────
def _score_to_grade(score: float) -> str:
"""Map 0–100 composite score to letter grade."""
if score >= 85:
return "A+"
if score >= 70:
return "A"
if score >= 55:
return "B"
if score >= 40:
return "C"
return "D"
def grade_to_color(grade: str) -> str:
"""Map grade to hex colour for UI rendering."""
return {
"A+": "#059669", # strong green
"A": "#10b981", # green
"B": "#d97706", # amber
"C": "#f97316", # orange
"D": "#dc2626", # red
}.get(grade, "#8c95b2")
# ─────────────────────────────────────────────────────────────────────────────
# Subscoring helpers
# ─────────────────────────────────────────────────────────────────────────────
def _structure_score(tf_data: dict, direction: str) -> tuple[float, list[str]]:
"""0–100 score for EMA structure + S/R alignment."""
notes = []
h1 = tf_data.get("1h", {})
m15 = tf_data.get("15m", {})
if "error" in h1 or "error" in m15:
return 40.0, ["insufficient structure data"]
s1h = h1.get("structure", "range")
s15m = m15.get("structure", "range")
dir_struct = "uptrend" if direction == "long" else "downtrend"
if s1h == dir_struct and s15m == dir_struct:
score = 90.0
notes.append(f"Both 1h + 15m in {dir_struct} — maximum structure alignment")
elif s1h == dir_struct:
score = 70.0
notes.append(f"1h {dir_struct} confirmed; 15m lagging — partial alignment")
elif s15m == dir_struct:
score = 55.0
notes.append(f"15m in {dir_struct}; 1h not yet — lower timeframe lead")
elif s1h == "range" and s15m == "range":
score = 35.0
notes.append("Both timeframes ranging — structure undefined")
else:
score = 20.0
notes.append(f"Structure conflict: 1h {s1h} vs 15m {s15m} — counter-trend risk")
# S/R bonus: reward if entry is close to support (long) or resistance (short)
close = m15.get("close", 0)
atr = m15.get("atr14", 0)
sup = m15.get("support")
res = m15.get("resistance")
if atr > 0 and close > 0:
if direction == "long" and sup and abs(close - sup) <= atr:
score = min(score + 8, 100)
notes.append("Entry at support — tight risk location")
elif direction == "short" and res and abs(close - res) <= atr:
score = min(score + 8, 100)
notes.append("Entry at resistance — tight risk location")
return round(score, 1), notes
def _pattern_subscore(cs_patterns: list, cp_patterns: list,
direction: str) -> tuple[float, list[str]]:
"""0–100 score for candlestick + chart patterns in trade direction."""
notes = []
score = 50.0
dir_signal = "bullish" if direction == "long" else "bearish"
# Candlestick patterns
aligned_cs = [p for p in cs_patterns if p.get("signal") == dir_signal]
opposing_cs = [p for p in cs_patterns if p.get("signal") not in (dir_signal, "neutral")]
for p in aligned_cs:
strength = p.get("strength", 2)
boost = {3: 15, 2: 10, 1: 5}.get(strength, 5)
score = min(score + boost, 100)
key = " (KEY)" if p.get("at_key_level") else ""
notes.append(f"{p['name']}{key} — {p.get('signal','?')}")
for p in opposing_cs:
score = max(score - 12, 0)
notes.append(f"⚠ {p['name']} opposes direction")
# Chart patterns
aligned_cp = [p for p in cp_patterns if p.get("signal") == dir_signal]
opp_cp_signal = "bearish" if direction == "long" else "bullish"
opposing_cp = [p for p in cp_patterns if p.get("signal") == opp_cp_signal]
for p in aligned_cp:
stage_boost = {"confirmed": 20, "forming": 10, "broken": 5}.get(p.get("stage", "forming"), 10)
score = min(score + stage_boost, 100)
notes.append(f"{p['name']} ({p.get('stage','?')}) — {p.get('signal','?')}")
for p in opposing_cp:
score = max(score - 15, 0)
notes.append(f"⚠ {p['name']} chart pattern opposes direction")
if not cs_patterns and not cp_patterns:
notes.append("No patterns detected — structure only")
return round(score, 1), notes
def _ob_subscore(ob_result: dict, direction: str) -> tuple[float, list[str]]:
"""0–100 score for order block / FVG context."""
notes = []
score = 50.0
ob_below = ob_result.get("nearest_ob_below")
ob_above = ob_result.get("nearest_ob_above")
fvg_below = ob_result.get("nearest_fvg_below")
fvg_above = ob_result.get("nearest_fvg_above")
if direction == "long":
if ob_below:
if ob_below["status"] == "fresh":
score += 30; notes.append(f"Fresh bullish OB below — institutional support zone")
elif ob_below["status"] == "tested":
score += 18; notes.append(f"Tested bullish OB below — proven support, higher risk")
elif ob_below["status"] == "breaker":
score -= 20; notes.append(f"⚠ Breaker block below — former support is now resistance")
if fvg_above:
score += 10; notes.append("Unfilled bullish FVG above — price magnet target")
if ob_above and ob_above["status"] == "fresh":
score -= 10; notes.append("Fresh bearish OB above — resistance cap")
elif direction == "short":
if ob_above:
if ob_above["status"] == "fresh":
score += 30; notes.append(f"Fresh bearish OB above — institutional resistance zone")
elif ob_above["status"] == "tested":
score += 18; notes.append(f"Tested bearish OB above — proven resistance")
elif ob_above["status"] == "breaker":
score -= 20; notes.append(f"⚠ Breaker block above — former resistance is now support")
if fvg_below:
score += 10; notes.append("Unfilled bearish FVG below — price magnet target")
if ob_below and ob_below["status"] == "fresh":
score -= 10; notes.append("Fresh bullish OB below — support floor")
ob_summary = ob_result.get("summary", "")
if ob_summary and ob_summary != "No significant OB or FVG in range":
notes.append(ob_summary)
return round(max(0, min(100, score)), 1), notes
def _momentum_subscore(tf_data: dict, direction: str,
funding_rate=None) -> tuple[float, list[str]]:
"""0–100 score for RSI + volume + funding momentum."""
notes = []
m15 = tf_data.get("15m", {})
h1 = tf_data.get("1h", {})
rsi = m15.get("rsi14", 50.0)
vol_ratio = m15.get("vol_ratio")
score = 50.0
# RSI
if direction == "long":
if rsi <= 30:
score += 25; notes.append(f"RSI {rsi:.1f} — oversold, strong long momentum")
elif rsi <= 45:
score += 12; notes.append(f"RSI {rsi:.1f} — below midline, bullish bias")
elif rsi >= 70:
score -= 15; notes.append(f"RSI {rsi:.1f} — overbought, long momentum stretched")
else:
notes.append(f"RSI {rsi:.1f} — neutral zone")
else: # short
if rsi >= 70:
score += 25; notes.append(f"RSI {rsi:.1f} — overbought, strong short momentum")
elif rsi >= 55:
score += 12; notes.append(f"RSI {rsi:.1f} — above midline, bearish bias")
elif rsi <= 30:
score -= 15; notes.append(f"RSI {rsi:.1f} — oversold, short momentum stretched")
else:
notes.append(f"RSI {rsi:.1f} — neutral zone")
# Volume
if vol_ratio is not None:
if vol_ratio >= 1.5:
score += 10; notes.append(f"Volume {vol_ratio:.1f}× average — strong confirmation")
elif vol_ratio < 0.7:
score -= 8; notes.append(f"Volume {vol_ratio:.1f}× average — weak, low conviction")
# Funding rate
if funding_rate is not None:
fr_pct = funding_rate * 100
if direction == "long" and fr_pct < -0.02:
score += 8; notes.append(f"Funding negative ({fr_pct:.4f}%) — short squeeze potential")
elif direction == "long" and fr_pct > 0.05:
score -= 8; notes.append(f"Funding high ({fr_pct:.4f}%) — longs crowded")
elif direction == "short" and fr_pct > 0.05:
score += 8; notes.append(f"Funding high ({fr_pct:.4f}%) — longs over-extended")
elif direction == "short" and fr_pct < -0.02:
score -= 8; notes.append(f"Funding negative ({fr_pct:.4f}%) — shorts crowded")
return round(max(0, min(100, score)), 1), notes
# ─────────────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────────────
def analyze_confluence(tf_data: dict,
cs_patterns_15m: list,
cs_patterns_1h: list,
chart_patterns: list,
ob_result: dict,
direction: str,
funding_rate=None) -> dict:
"""Compute full multi-TF confluence analysis.
Args:
tf_data: Output of analyze_timeframe() for each TF.
cs_patterns_15m: Candlestick patterns from 15m df.
cs_patterns_1h: Candlestick patterns from 1h df.
chart_patterns: Chart patterns from detect_chart_patterns().
ob_result: Output of detect_order_blocks().
direction: "long" | "short"
funding_rate: Raw funding rate float or None.
Returns dict:
direction str
confluence_score float (0–100)
grade str (A+/A/B/C/D)
grade_color str (hex)
confirming list[str]
conflicting list[str]
structure_score float
pattern_score float
ob_score float
momentum_score float
top_cs_pattern dict | None
top_chart_pattern dict | None
ob_context str
pattern_conflict bool
"""
# Combine 15m + 1h candlestick patterns (15m patterns take priority)
all_cs = cs_patterns_15m + [p for p in cs_patterns_1h
if not any(q["name"] == p["name"] for q in cs_patterns_15m)]
# Sub-scores (each 0–100)
s_struct, n_struct = _structure_score(tf_data, direction)
s_pats, n_pats = _pattern_subscore(all_cs, chart_patterns, direction)
s_ob, n_ob = _ob_subscore(ob_result, direction)
s_mom, n_mom = _momentum_subscore(tf_data, direction, funding_rate)
# Weighted composite (35 / 25 / 20 / 20)
composite = (
0.35 * s_struct +
0.25 * s_pats +
0.20 * s_ob +
0.20 * s_mom
)
composite = round(max(0.0, min(100.0, composite)), 1)
grade = _score_to_grade(composite)
color = grade_to_color(grade)
# Split notes into confirming vs conflicting
dir_label = "bullish" if direction == "long" else "bearish"
confirming = [n for n in n_struct + n_pats + n_ob + n_mom
if "⚠" not in n and "conflict" not in n.lower()
and "opposing" not in n.lower()]
conflicting = [n for n in n_struct + n_pats + n_ob + n_mom
if "⚠" in n or "conflict" in n.lower() or "opposing" in n.lower()]
# Best patterns to surface on the card
dir_signal = "bullish" if direction == "long" else "bearish"
top_cs = next((p for p in all_cs if p.get("signal") == dir_signal), None)
top_cp = next((p for p in chart_patterns if p.get("signal") == dir_signal), None)
# Pattern direction conflict flag
opp_signal = "bearish" if direction == "long" else "bullish"
has_opposing_cs = any(p.get("signal") == opp_signal for p in all_cs)
has_opposing_cp = any(p.get("signal") == opp_signal for p in chart_patterns)
pattern_conflict = has_opposing_cs or has_opposing_cp
return {
"direction": direction,
"confluence_score": composite,
"grade": grade,
"grade_color": color,
"confirming": confirming[:6],
"conflicting": conflicting[:4],
"structure_score": s_struct,
"pattern_score": s_pats,
"ob_score": s_ob,
"momentum_score": s_mom,
"top_cs_pattern": top_cs,
"top_chart_pattern": top_cp,
"ob_context": ob_result.get("summary", ""),
"pattern_conflict": pattern_conflict,
}
|