File size: 16,711 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
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
"""Candlestick pattern detector — 14 patterns on CLOSED candles only.

Patterns are tiered by reliability:
  Tier 1 (strength 3): Pattern at a key S/R level (within 0.5 × ATR)
  Tier 2 (strength 2): Clear pattern, no S/R confluence
  Tier 3 (strength 1): Marginal / small-body pattern

Detection runs on the last 5 closed candles of a DataFrame that already
has the forming candle dropped by the caller.

All returns are JSON-serialisable dicts — no pandas objects.
"""
from __future__ import annotations
import math
import pandas as pd


# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────

def _body(o, c):
    return abs(c - o)

def _upper_wick(o, c, h):
    return h - max(o, c)

def _lower_wick(o, c, l):
    return min(o, c) - l

def _is_bullish(o, c):
    return c > o

def _is_bearish(o, c):
    return c < o

def _at_key_level(price: float, support, resistance, atr: float) -> bool:
    """True if price is within 0.5 × ATR of a support or resistance level."""
    if atr <= 0:
        return False
    if support is not None and abs(price - support) <= 0.5 * atr:
        return True
    if resistance is not None and abs(price - resistance) <= 0.5 * atr:
        return True
    return False

def _strength(at_key: bool) -> int:
    return 3 if at_key else 2

def _make(name: str, signal: str, at_key: bool, description: str) -> dict:
    return {
        "name": name,
        "signal": signal,           # "bullish" | "bearish" | "neutral"
        "strength": _strength(at_key),
        "at_key_level": at_key,
        "description": description,
    }


# ─────────────────────────────────────────────────────────────────────────────
# Single-candle patterns
# ─────────────────────────────────────────────────────────────────────────────

def _pin_bar(o, h, l, c, atr, support, resistance) -> dict | None:
    """Long lower wick ≥ 2× body, short upper wick ≤ 0.3× body → bullish reversal."""
    body = _body(o, c)
    if body < atr * 0.05:   # ignore doji-level bodies
        return None
    lower = _lower_wick(o, c, l)
    upper = _upper_wick(o, c, h)
    if lower >= 2.0 * body and upper <= 0.3 * body:
        ak = _at_key_level(l, support, resistance, atr)
        return _make("Pin Bar", "bullish", ak,
                     f"Long lower shadow ({lower/body:.1f}× body) shows rejection of lower prices.")
    return None


def _shooting_star(o, h, l, c, atr, support, resistance) -> dict | None:
    """Long upper wick ≥ 2× body, short lower wick → bearish reversal at highs."""
    body = _body(o, c)
    if body < atr * 0.05:
        return None
    upper = _upper_wick(o, c, h)
    lower = _lower_wick(o, c, l)
    if upper >= 2.0 * body and lower <= 0.3 * body:
        ak = _at_key_level(h, support, resistance, atr)
        return _make("Shooting Star", "bearish", ak,
                     f"Long upper shadow ({upper/body:.1f}× body) shows rejection of higher prices.")
    return None


def _doji(o, h, l, c, atr, support, resistance) -> dict | None:
    """Body ≤ 5% of full candle range → indecision / potential reversal."""
    full_range = h - l
    if full_range < atr * 0.1:
        return None
    body = _body(o, c)
    if body <= 0.05 * full_range:
        ak = _at_key_level(c, support, resistance, atr)
        return _make("Doji", "neutral", ak,
                     "Near-equal open/close signals market indecision — watch for breakout candle.")
    return None


def _marubozu(o, h, l, c, atr, support, resistance) -> dict | None:
    """Body covers ≥ 90% of candle range, tiny wicks → strong momentum."""
    full_range = h - l
    if full_range < atr * 0.2:
        return None
    body = _body(o, c)
    if body >= 0.90 * full_range:
        if _is_bullish(o, c):
            ak = _at_key_level(l, support, resistance, atr)
            return _make("Bullish Marubozu", "bullish", ak,
                         "Full-body bullish candle — strong buying pressure, minimal wick.")
        else:
            ak = _at_key_level(h, support, resistance, atr)
            return _make("Bearish Marubozu", "bearish", ak,
                         "Full-body bearish candle — strong selling pressure, minimal wick.")
    return None


# ─────────────────────────────────────────────────────────────────────────────
# Two-candle patterns
# ─────────────────────────────────────────────────────────────────────────────

def _engulfing(p_o, p_h, p_l, p_c,
               c_o, c_h, c_l, c_c,
               atr, support, resistance) -> dict | None:
    """Current candle body fully engulfs prior candle body."""
    p_body = _body(p_o, p_c)
    c_body = _body(c_o, c_c)
    if p_body < atr * 0.05 or c_body < p_body:
        return None
    if _is_bullish(p_o, p_c) and _is_bearish(c_o, c_c):
        # Bearish engulfing: current bearish body covers bullish prior
        if c_o >= p_c and c_c <= p_o:
            ak = _at_key_level(c_h, support, resistance, atr)
            return _make("Bearish Engulfing", "bearish", ak,
                         "Bears fully reversed the prior bullish candle — momentum shift down.")
    elif _is_bearish(p_o, p_c) and _is_bullish(c_o, c_c):
        # Bullish engulfing: current bullish body covers bearish prior
        if c_o <= p_c and c_c >= p_o:
            ak = _at_key_level(c_l, support, resistance, atr)
            return _make("Bullish Engulfing", "bullish", ak,
                         "Bulls fully reversed the prior bearish candle — momentum shift up.")
    return None


def _harami(p_o, p_h, p_l, p_c,
            c_o, c_h, c_l, c_c,
            atr, support, resistance) -> dict | None:
    """Small current candle body contained within prior large candle body."""
    p_body = _body(p_o, p_c)
    c_body = _body(c_o, c_c)
    if p_body < atr * 0.3 or c_body >= p_body * 0.5:
        return None
    p_top    = max(p_o, p_c)
    p_bottom = min(p_o, p_c)
    c_top    = max(c_o, c_c)
    c_bottom = min(c_o, c_c)
    if c_top <= p_top and c_bottom >= p_bottom:
        if _is_bearish(p_o, p_c) and _is_bullish(c_o, c_c):
            ak = _at_key_level(p_l, support, resistance, atr)
            return _make("Bullish Harami", "bullish", ak,
                         "Small bullish body inside large bearish candle — selling momentum fading.")
        elif _is_bullish(p_o, p_c) and _is_bearish(c_o, c_c):
            ak = _at_key_level(p_h, support, resistance, atr)
            return _make("Bearish Harami", "bearish", ak,
                         "Small bearish body inside large bullish candle — buying momentum fading.")
    return None


def _tweezer(p_o, p_h, p_l, p_c,
             c_o, c_h, c_l, c_c,
             atr, support, resistance) -> dict | None:
    """Two candles with matching highs (top) or matching lows (bottom)."""
    tol = atr * 0.05
    if abs(p_l - c_l) <= tol and _is_bearish(p_o, p_c) and _is_bullish(c_o, c_c):
        ak = _at_key_level(c_l, support, resistance, atr)
        return _make("Tweezer Bottom", "bullish", ak,
                     "Matching lows with opposite-colored candles — double rejection of the low.")
    if abs(p_h - c_h) <= tol and _is_bullish(p_o, p_c) and _is_bearish(c_o, c_c):
        ak = _at_key_level(c_h, support, resistance, atr)
        return _make("Tweezer Top", "bearish", ak,
                     "Matching highs with opposite-colored candles — double rejection of the high.")
    return None


# ─────────────────────────────────────────────────────────────────────────────
# Three-candle patterns
# ─────────────────────────────────────────────────────────────────────────────

def _morning_star(c1_o, c1_h, c1_l, c1_c,
                  c2_o, c2_h, c2_l, c2_c,
                  c3_o, c3_h, c3_l, c3_c,
                  atr, support, resistance) -> dict | None:
    """Bearish → small body gap down → bullish close above midpoint of c1."""
    c1_body = _body(c1_o, c1_c)
    c3_body = _body(c3_o, c3_c)
    c2_body = _body(c2_o, c2_c)
    if c1_body < atr * 0.3 or c3_body < atr * 0.3:
        return None
    c1_mid = (c1_o + c1_c) / 2
    if (_is_bearish(c1_o, c1_c)
            and c2_body <= c1_body * 0.4   # small star
            and _is_bullish(c3_o, c3_c)
            and c3_c > c1_mid):
        ak = _at_key_level(c1_l, support, resistance, atr)
        return _make("Morning Star", "bullish", ak,
                     "Three-candle reversal: bearish → indecision star → bullish recovery above midpoint.")
    return None


def _evening_star(c1_o, c1_h, c1_l, c1_c,
                  c2_o, c2_h, c2_l, c2_c,
                  c3_o, c3_h, c3_l, c3_c,
                  atr, support, resistance) -> dict | None:
    """Bullish → small body gap up → bearish close below midpoint of c1."""
    c1_body = _body(c1_o, c1_c)
    c3_body = _body(c3_o, c3_c)
    c2_body = _body(c2_o, c2_c)
    if c1_body < atr * 0.3 or c3_body < atr * 0.3:
        return None
    c1_mid = (c1_o + c1_c) / 2
    if (_is_bullish(c1_o, c1_c)
            and c2_body <= c1_body * 0.4
            and _is_bearish(c3_o, c3_c)
            and c3_c < c1_mid):
        ak = _at_key_level(c1_h, support, resistance, atr)
        return _make("Evening Star", "bearish", ak,
                     "Three-candle reversal: bullish → indecision star → bearish close below midpoint.")
    return None


def _three_soldiers(candles: list, atr: float, support, resistance) -> dict | None:
    """Three consecutive bullish candles, each closing higher than the last."""
    if len(candles) < 3:
        return None
    c1, c2, c3 = candles[-3], candles[-2], candles[-1]
    o1, c1c = c1["open"], c1["close"]
    o2, c2c = c2["open"], c2["close"]
    o3, c3c = c3["open"], c3["close"]
    min_body = atr * 0.3
    if (all(_is_bullish(o, c) for o, c in [(o1,c1c),(o2,c2c),(o3,c3c)])
            and all(_body(o, c) >= min_body for o, c in [(o1,c1c),(o2,c2c),(o3,c3c)])
            and c1c < c2c < c3c
            and o2 > o1 and o3 > o2):
        ak = _at_key_level(c3c, support, resistance, atr)
        return _make("Three White Soldiers", "bullish", ak,
                     "Three consecutive strong bullish candles — sustained buying conviction.")
    return None


def _three_crows(candles: list, atr: float, support, resistance) -> dict | None:
    """Three consecutive bearish candles, each closing lower than the last."""
    if len(candles) < 3:
        return None
    c1, c2, c3 = candles[-3], candles[-2], candles[-1]
    o1, c1c = c1["open"], c1["close"]
    o2, c2c = c2["open"], c2["close"]
    o3, c3c = c3["open"], c3["close"]
    min_body = atr * 0.3
    if (all(_is_bearish(o, c) for o, c in [(o1,c1c),(o2,c2c),(o3,c3c)])
            and all(_body(o, c) >= min_body for o, c in [(o1,c1c),(o2,c2c),(o3,c3c)])
            and c1c > c2c > c3c
            and o2 < o1 and o3 < o2):
        ak = _at_key_level(c3c, support, resistance, atr)
        return _make("Three Black Crows", "bearish", ak,
                     "Three consecutive strong bearish candles — sustained selling conviction.")
    return None


# ─────────────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────────────

def detect_patterns(df: pd.DataFrame,
                    support=None,
                    resistance=None,
                    atr: float = 0.0) -> list[dict]:
    """Detect all candlestick patterns in the last 5 closed candles.

    Args:
        df:          OHLCV DataFrame, forming bar already dropped by caller.
        support:     Nearest support level (float or None).
        resistance:  Nearest resistance level (float or None).
        atr:         14-period ATR value for body-size thresholds.

    Returns:
        List of pattern dicts, most-recent first.
        Empty list if fewer than 3 candles or ATR is zero.
    """
    if len(df) < 3 or atr <= 0:
        return []

    # Work with last 5 candles only (sufficient for all patterns here)
    tail = df.tail(5)
    rows = [
        {"open": float(r["open"]), "high": float(r["high"]),
         "low": float(r["low"]), "close": float(r["close"])}
        for _, r in tail.iterrows()
    ]

    found: list[dict] = []

    # ── Latest candle (index -1) ─────────────────────────────────────────────
    c = rows[-1]
    o, h, l, cv = c["open"], c["high"], c["low"], c["close"]

    for fn in [_pin_bar, _shooting_star, _doji, _marubozu]:
        result = fn(o, h, l, cv, atr, support, resistance)
        if result:
            found.append(result)

    # ── Two-candle (prior + current) ─────────────────────────────────────────
    if len(rows) >= 2:
        p = rows[-2]
        for fn in [_engulfing, _harami, _tweezer]:
            result = fn(p["open"], p["high"], p["low"], p["close"],
                        o, h, l, cv, atr, support, resistance)
            if result:
                found.append(result)

    # ── Three-candle (c1, c2, current) ──────────────────────────────────────
    if len(rows) >= 3:
        c1, c2 = rows[-3], rows[-2]
        for fn in [_morning_star, _evening_star]:
            result = fn(c1["open"], c1["high"], c1["low"], c1["close"],
                        c2["open"], c2["high"], c2["low"], c2["close"],
                        o, h, l, cv, atr, support, resistance)
            if result:
                found.append(result)

        for fn in [_three_soldiers, _three_crows]:
            result = fn(rows, atr, support, resistance)
            if result:
                found.append(result)

    # De-duplicate: keep highest-strength version of each name
    seen: dict[str, dict] = {}
    for pat in found:
        name = pat["name"]
        if name not in seen or pat["strength"] > seen[name]["strength"]:
            seen[name] = pat
    return list(seen.values())


def pattern_score(patterns: list[dict], max_score: float = 10.0) -> float:
    """Convert pattern list to a 0–10 score.

    Scoring:
        strength-3 pattern: 4 pts
        strength-2 pattern: 2.5 pts
        strength-1 pattern: 1 pt
    Multiple patterns are additive but capped at max_score.
    Returns 5.0 (neutral) if no patterns detected.
    """
    if not patterns:
        return 5.0
    pts = {3: 4.0, 2: 2.5, 1: 1.0}
    total = sum(pts.get(p["strength"], 1.0) for p in patterns)
    return round(min(5.0 + total, max_score), 2)


def pattern_signal(patterns: list[dict]) -> str:
    """Aggregate signal direction from all patterns.

    Returns "bullish", "bearish", or "neutral" based on majority.
    """
    if not patterns:
        return "neutral"
    bull = sum(1 for p in patterns if p["signal"] == "bullish")
    bear = sum(1 for p in patterns if p["signal"] == "bearish")
    if bull > bear:
        return "bullish"
    if bear > bull:
        return "bearish"
    return "neutral"