Spaces:
Running
Running
feat: price action analysis — candlestick patterns, chart patterns, OBs, FVGs, confluence grading
cb145d1 | """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, | |
| } | |