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