trade-copilot / confluence.py
utkarshpathak48's picture
feat: price action analysis — candlestick patterns, chart patterns, OBs, FVGs, confluence grading
cb145d1
Raw
History Blame Contribute Delete
14.5 kB
"""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,
}