"""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, }