Spaces:
Running
Running
feat: price action analysis — candlestick patterns, chart patterns, OBs, FVGs, confluence grading
cb145d1 | """Classical chart pattern detector. | |
| Patterns detected (11 total): | |
| Reversals: Double Top, Double Bottom, Head & Shoulders, Inverse H&S | |
| Continuations: Bull Flag, Bear Flag, Ascending Triangle, Descending Triangle, | |
| Symmetrical Triangle, Rising Wedge, Falling Wedge | |
| Uses swing pivot highs/lows + numpy polyfit for trendline slope. | |
| Each pattern has a stage: forming / confirmed / broken. | |
| All returns are JSON-serialisable — no pandas objects. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import pandas as pd | |
| import numpy as np | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Helpers | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def _swing_pivots(df: pd.DataFrame, k: int = 5) -> tuple[list, list]: | |
| """Return (pivot_highs, pivot_lows) as (index, price) tuples.""" | |
| highs, lows = [], [] | |
| h, l = df["high"].values, df["low"].values | |
| for i in range(k, len(df) - k): | |
| wh = h[i - k: i + k + 1] | |
| wl = l[i - k: i + k + 1] | |
| if h[i] == wh.max() and (wh == h[i]).sum() == 1: | |
| highs.append((i, float(h[i]))) | |
| if l[i] == wl.min() and (wl == l[i]).sum() == 1: | |
| lows.append((i, float(l[i]))) | |
| return highs, lows | |
| def _slope(points: list[tuple]) -> float: | |
| """Linear regression slope of (index, price) points.""" | |
| if len(points) < 2: | |
| return 0.0 | |
| xs = np.array([p[0] for p in points], dtype=float) | |
| ys = np.array([p[1] for p in points], dtype=float) | |
| coeffs = np.polyfit(xs, ys, 1) | |
| return float(coeffs[0]) | |
| def _pct_diff(a: float, b: float) -> float: | |
| """Absolute % difference between a and b.""" | |
| if b == 0: | |
| return 0.0 | |
| return abs(a - b) / b * 100 | |
| def _make(name: str, signal: str, stage: str, target_pct: float | None, | |
| description: str) -> dict: | |
| return { | |
| "name": name, | |
| "signal": signal, # "bullish" | "bearish" | |
| "stage": stage, # "forming" | "confirmed" | "broken" | |
| "target_pct": round(target_pct, 1) if target_pct is not None else None, | |
| "description": description, | |
| } | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Reversal patterns | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def _double_top(highs: list, lows: list, close: float, atr: float) -> dict | None: | |
| """Two peaks at similar price separated by a trough (neckline). | |
| Stage: | |
| forming — second peak forming (not yet broken below neckline) | |
| confirmed — close broke below neckline | |
| """ | |
| if len(highs) < 2 or len(lows) < 1: | |
| return None | |
| h1, h2 = highs[-2], highs[-1] | |
| if h1[0] >= h2[0]: | |
| return None | |
| if _pct_diff(h1[1], h2[1]) > 3.0: # peaks must be within 3% | |
| return None | |
| # Neckline = lowest low between the two peaks | |
| between = [lv for lv in lows if h1[0] < lv[0] < h2[0]] | |
| if not between: | |
| return None | |
| neckline = min(lv[1] for lv in between) | |
| pattern_height = max(h1[1], h2[1]) - neckline | |
| target_pct = pattern_height / neckline * 100 | |
| if close < neckline: | |
| stage = "confirmed" | |
| desc = f"Double Top confirmed — broke below neckline {neckline:.4g}. Target: -{target_pct:.1f}%." | |
| else: | |
| stage = "forming" | |
| desc = f"Double Top forming — two peaks near {h1[1]:.4g}, neckline {neckline:.4g}." | |
| return _make("Double Top", "bearish", stage, target_pct, desc) | |
| def _double_bottom(highs: list, lows: list, close: float, atr: float) -> dict | None: | |
| """Two troughs at similar price separated by a peak (neckline).""" | |
| if len(lows) < 2 or len(highs) < 1: | |
| return None | |
| l1, l2 = lows[-2], lows[-1] | |
| if l1[0] >= l2[0]: | |
| return None | |
| if _pct_diff(l1[1], l2[1]) > 3.0: | |
| return None | |
| between = [hv for hv in highs if l1[0] < hv[0] < l2[0]] | |
| if not between: | |
| return None | |
| neckline = max(hv[1] for hv in between) | |
| pattern_height = neckline - min(l1[1], l2[1]) | |
| target_pct = pattern_height / neckline * 100 | |
| if close > neckline: | |
| stage = "confirmed" | |
| desc = f"Double Bottom confirmed — broke above neckline {neckline:.4g}. Target: +{target_pct:.1f}%." | |
| else: | |
| stage = "forming" | |
| desc = f"Double Bottom forming — two troughs near {l1[1]:.4g}, neckline {neckline:.4g}." | |
| return _make("Double Bottom", "bullish", stage, target_pct, desc) | |
| def _head_and_shoulders(highs: list, lows: list, close: float, atr: float) -> dict | None: | |
| """Left shoulder / head (highest) / right shoulder — bearish reversal.""" | |
| if len(highs) < 3: | |
| return None | |
| ls, head, rs = highs[-3], highs[-2], highs[-1] | |
| if not (ls[0] < head[0] < rs[0]): | |
| return None | |
| if not (head[1] > ls[1] and head[1] > rs[1]): | |
| return None | |
| if _pct_diff(ls[1], rs[1]) > 5.0: # shoulders roughly equal | |
| return None | |
| # Neckline: average of troughs between shoulders | |
| t_left = [lv for lv in lows if ls[0] < lv[0] < head[0]] | |
| t_right = [lv for lv in lows if head[0] < lv[0] < rs[0]] | |
| if not t_left or not t_right: | |
| return None | |
| nl_left = min(lv[1] for lv in t_left) | |
| nl_right = min(lv[1] for lv in t_right) | |
| neckline = (nl_left + nl_right) / 2 | |
| pattern_height = head[1] - neckline | |
| target_pct = pattern_height / neckline * 100 | |
| if close < neckline: | |
| stage = "confirmed" | |
| desc = f"Head & Shoulders confirmed — neckline {neckline:.4g} broken. Target: -{target_pct:.1f}%." | |
| else: | |
| stage = "forming" | |
| desc = f"H&S forming — head at {head[1]:.4g}, neckline ~{neckline:.4g}." | |
| return _make("Head & Shoulders", "bearish", stage, target_pct, desc) | |
| def _inverse_hs(highs: list, lows: list, close: float, atr: float) -> dict | None: | |
| """Inverse H&S (head is lowest) — bullish reversal.""" | |
| if len(lows) < 3: | |
| return None | |
| ls, head, rs = lows[-3], lows[-2], lows[-1] | |
| if not (ls[0] < head[0] < rs[0]): | |
| return None | |
| if not (head[1] < ls[1] and head[1] < rs[1]): | |
| return None | |
| if _pct_diff(ls[1], rs[1]) > 5.0: | |
| return None | |
| t_left = [hv for hv in highs if ls[0] < hv[0] < head[0]] | |
| t_right = [hv for hv in highs if head[0] < hv[0] < rs[0]] | |
| if not t_left or not t_right: | |
| return None | |
| nl_left = max(hv[1] for hv in t_left) | |
| nl_right = max(hv[1] for hv in t_right) | |
| neckline = (nl_left + nl_right) / 2 | |
| pattern_height = neckline - head[1] | |
| target_pct = pattern_height / neckline * 100 | |
| if close > neckline: | |
| stage = "confirmed" | |
| desc = f"Inverse H&S confirmed — neckline {neckline:.4g} broken. Target: +{target_pct:.1f}%." | |
| else: | |
| stage = "forming" | |
| desc = f"Inverse H&S forming — head at {head[1]:.4g}, neckline ~{neckline:.4g}." | |
| return _make("Inverse Head & Shoulders", "bullish", stage, target_pct, desc) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Continuation patterns | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def _bull_flag(df: pd.DataFrame, highs: list, lows: list, | |
| close: float, atr: float) -> dict | None: | |
| """Strong rally → brief downward-sloping consolidation channel → bull continuation.""" | |
| if len(df) < 30 or len(highs) < 2 or len(lows) < 2: | |
| return None | |
| # Flagpole: look for ≥5% rise in last 20 bars | |
| recent = df["close"].values[-20:] | |
| pole_low = recent.min() | |
| pole_high = recent.max() | |
| if pole_high <= 0 or (pole_high - pole_low) / pole_low < 0.05: | |
| return None | |
| # Flag: last 10 bars should have slight downward slope | |
| flag_highs = [hv for hv in highs if hv[0] >= len(df) - 15] | |
| flag_lows = [lv for lv in lows if lv[0] >= len(df) - 15] | |
| if len(flag_highs) < 2 or len(flag_lows) < 2: | |
| return None | |
| slope_h = _slope(flag_highs) | |
| slope_l = _slope(flag_lows) | |
| if slope_h >= 0 or slope_l >= 0: # both lines must slope down | |
| return None | |
| # Flag channel must be tighter than the pole | |
| flag_range = max(hv[1] for hv in flag_highs) - min(lv[1] for lv in flag_lows) | |
| if flag_range > (pole_high - pole_low) * 0.7: | |
| return None | |
| target_pct = (pole_high - pole_low) / pole_low * 100 | |
| resistance_line = max(hv[1] for hv in flag_highs) | |
| if close > resistance_line: | |
| stage = "confirmed" | |
| desc = f"Bull Flag confirmed — breakout above flag resistance {resistance_line:.4g}. Target: +{target_pct:.1f}%." | |
| else: | |
| stage = "forming" | |
| desc = f"Bull Flag forming — tight consolidation after {target_pct:.1f}% rally. Watch for breakout above {resistance_line:.4g}." | |
| return _make("Bull Flag", "bullish", stage, target_pct, desc) | |
| def _bear_flag(df: pd.DataFrame, highs: list, lows: list, | |
| close: float, atr: float) -> dict | None: | |
| """Strong decline → brief upward-sloping consolidation → bear continuation.""" | |
| if len(df) < 30 or len(highs) < 2 or len(lows) < 2: | |
| return None | |
| recent = df["close"].values[-20:] | |
| pole_high = recent.max() | |
| pole_low = recent.min() | |
| if pole_low <= 0 or (pole_high - pole_low) / pole_high < 0.05: | |
| return None | |
| flag_highs = [hv for hv in highs if hv[0] >= len(df) - 15] | |
| flag_lows = [lv for lv in lows if lv[0] >= len(df) - 15] | |
| if len(flag_highs) < 2 or len(flag_lows) < 2: | |
| return None | |
| slope_h = _slope(flag_highs) | |
| slope_l = _slope(flag_lows) | |
| if slope_h <= 0 or slope_l <= 0: # both lines must slope up | |
| return None | |
| flag_range = max(hv[1] for hv in flag_highs) - min(lv[1] for lv in flag_lows) | |
| if flag_range > (pole_high - pole_low) * 0.7: | |
| return None | |
| target_pct = (pole_high - pole_low) / pole_high * 100 | |
| support_line = min(lv[1] for lv in flag_lows) | |
| if close < support_line: | |
| stage = "confirmed" | |
| desc = f"Bear Flag confirmed — breakdown below support {support_line:.4g}. Target: -{target_pct:.1f}%." | |
| else: | |
| stage = "forming" | |
| desc = f"Bear Flag forming — tight relief bounce after {target_pct:.1f}% drop. Watch for breakdown below {support_line:.4g}." | |
| return _make("Bear Flag", "bearish", stage, target_pct, desc) | |
| def _ascending_triangle(highs: list, lows: list, close: float, atr: float) -> dict | None: | |
| """Flat resistance + rising support → bullish breakout expected.""" | |
| if len(highs) < 3 or len(lows) < 3: | |
| return None | |
| recent_highs = highs[-4:] | |
| recent_lows = lows[-4:] | |
| slope_h = _slope(recent_highs) | |
| slope_l = _slope(recent_lows) | |
| flat_res = max(hv[1] for hv in recent_highs) | |
| # Resistance is flat (slope near 0), support rising | |
| if abs(slope_h) > atr * 0.02 or slope_l <= 0: | |
| return None | |
| target_pct = (flat_res - min(lv[1] for lv in recent_lows)) / flat_res * 100 | |
| if close > flat_res: | |
| stage = "confirmed" | |
| desc = f"Ascending Triangle confirmed — broke above {flat_res:.4g}. Target: +{target_pct:.1f}%." | |
| else: | |
| stage = "forming" | |
| desc = f"Ascending Triangle: flat resistance ~{flat_res:.4g}, rising support. Bullish bias on breakout." | |
| return _make("Ascending Triangle", "bullish", stage, target_pct, desc) | |
| def _descending_triangle(highs: list, lows: list, close: float, atr: float) -> dict | None: | |
| """Falling resistance + flat support → bearish breakdown expected.""" | |
| if len(highs) < 3 or len(lows) < 3: | |
| return None | |
| recent_highs = highs[-4:] | |
| recent_lows = lows[-4:] | |
| slope_h = _slope(recent_highs) | |
| slope_l = _slope(recent_lows) | |
| flat_sup = min(lv[1] for lv in recent_lows) | |
| if slope_h >= 0 or abs(slope_l) > atr * 0.02: | |
| return None | |
| target_pct = (max(hv[1] for hv in recent_highs) - flat_sup) / flat_sup * 100 | |
| if close < flat_sup: | |
| stage = "confirmed" | |
| desc = f"Descending Triangle confirmed — broke below {flat_sup:.4g}. Target: -{target_pct:.1f}%." | |
| else: | |
| stage = "forming" | |
| desc = f"Descending Triangle: falling resistance, flat support ~{flat_sup:.4g}. Bearish bias on breakdown." | |
| return _make("Descending Triangle", "bearish", stage, target_pct, desc) | |
| def _symmetrical_triangle(highs: list, lows: list, close: float, atr: float) -> dict | None: | |
| """Converging trendlines — breakout direction determines signal.""" | |
| if len(highs) < 3 or len(lows) < 3: | |
| return None | |
| recent_highs = highs[-4:] | |
| recent_lows = lows[-4:] | |
| slope_h = _slope(recent_highs) | |
| slope_l = _slope(recent_lows) | |
| # Resistance falling, support rising | |
| if slope_h >= 0 or slope_l <= 0: | |
| return None | |
| apex_high = max(hv[1] for hv in recent_highs) | |
| apex_low = min(lv[1] for lv in recent_lows) | |
| target_pct = (apex_high - apex_low) / apex_low * 100 | |
| if close > apex_high: | |
| stage = "confirmed" | |
| signal = "bullish" | |
| desc = f"Symmetrical Triangle: bullish breakout above {apex_high:.4g}. Target: +{target_pct:.1f}%." | |
| elif close < apex_low: | |
| stage = "confirmed" | |
| signal = "bearish" | |
| desc = f"Symmetrical Triangle: bearish breakdown below {apex_low:.4g}. Target: -{target_pct:.1f}%." | |
| else: | |
| stage = "forming" | |
| signal = "neutral" | |
| desc = f"Symmetrical Triangle compressing between {apex_low:.4g}–{apex_high:.4g}. Wait for breakout." | |
| # Use bullish as default signal for forming/neutral (slight upside bias in symmetrical) | |
| final_signal = signal if signal != "neutral" else "bullish" | |
| return _make("Symmetrical Triangle", final_signal, stage, target_pct, desc) | |
| def _rising_wedge(highs: list, lows: list, close: float, atr: float) -> dict | None: | |
| """Both trendlines rising but converging → bearish (overbought squeeze).""" | |
| if len(highs) < 3 or len(lows) < 3: | |
| return None | |
| recent_highs = highs[-4:] | |
| recent_lows = lows[-4:] | |
| slope_h = _slope(recent_highs) | |
| slope_l = _slope(recent_lows) | |
| # Both rising, but support steeper (converging) | |
| if slope_h <= 0 or slope_l <= 0 or slope_l <= slope_h: | |
| return None | |
| support_line = min(lv[1] for lv in recent_lows) | |
| target_pct = (max(hv[1] for hv in recent_highs) - support_line) / support_line * 100 | |
| if close < support_line: | |
| stage = "confirmed" | |
| desc = f"Rising Wedge confirmed — bearish breakdown below {support_line:.4g}. Target: -{target_pct:.1f}%." | |
| else: | |
| stage = "forming" | |
| desc = f"Rising Wedge: both trendlines rising but converging — bearish divergence building." | |
| return _make("Rising Wedge", "bearish", stage, target_pct, desc) | |
| def _falling_wedge(highs: list, lows: list, close: float, atr: float) -> dict | None: | |
| """Both trendlines falling but converging → bullish (oversold squeeze).""" | |
| if len(highs) < 3 or len(lows) < 3: | |
| return None | |
| recent_highs = highs[-4:] | |
| recent_lows = lows[-4:] | |
| slope_h = _slope(recent_highs) | |
| slope_l = _slope(recent_lows) | |
| # Both falling, but resistance steeper (converging) | |
| if slope_h >= 0 or slope_l >= 0 or slope_h >= slope_l: | |
| return None | |
| resistance_line = max(hv[1] for hv in recent_highs) | |
| target_pct = (resistance_line - min(lv[1] for lv in recent_lows)) / resistance_line * 100 | |
| if close > resistance_line: | |
| stage = "confirmed" | |
| desc = f"Falling Wedge confirmed — bullish breakout above {resistance_line:.4g}. Target: +{target_pct:.1f}%." | |
| else: | |
| stage = "forming" | |
| desc = f"Falling Wedge: both trendlines falling but converging — bullish coiling building." | |
| return _make("Falling Wedge", "bullish", stage, target_pct, desc) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Public API | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def detect_chart_patterns(df: pd.DataFrame, atr: float = 0.0) -> list[dict]: | |
| """Detect classical chart patterns in df. | |
| Args: | |
| df: OHLCV DataFrame, at least 30 bars, forming bar dropped. | |
| atr: 14-period ATR for threshold scaling. | |
| Returns: | |
| List of pattern dicts, most-confirmed patterns first. | |
| Empty list if insufficient data. | |
| """ | |
| if len(df) < 20 or atr <= 0: | |
| return [] | |
| close = float(df["close"].iloc[-1]) | |
| highs, lows = _swing_pivots(df, k=5) | |
| if not highs or not lows: | |
| return [] | |
| found: list[dict] = [] | |
| # Reversal patterns (check with last 20+ bars) | |
| for fn in [_double_top, _double_bottom, _head_and_shoulders, _inverse_hs]: | |
| try: | |
| r = fn(highs, lows, close, atr) | |
| if r: | |
| found.append(r) | |
| except Exception: | |
| pass | |
| # Continuation patterns | |
| for fn in [_bull_flag, _bear_flag]: | |
| try: | |
| r = fn(df, highs, lows, close, atr) | |
| if r: | |
| found.append(r) | |
| except Exception: | |
| pass | |
| for fn in [_ascending_triangle, _descending_triangle, | |
| _symmetrical_triangle, _rising_wedge, _falling_wedge]: | |
| try: | |
| r = fn(highs, lows, close, atr) | |
| if r: | |
| found.append(r) | |
| except Exception: | |
| pass | |
| # Sort: confirmed first, then by target_pct descending | |
| stage_order = {"confirmed": 0, "forming": 1, "broken": 2} | |
| found.sort(key=lambda p: ( | |
| stage_order.get(p["stage"], 3), | |
| -(p["target_pct"] or 0) | |
| )) | |
| return found | |
| def chart_pattern_score(patterns: list[dict]) -> float: | |
| """Convert chart pattern list to 0–10 score. | |
| Scoring: | |
| confirmed pattern: 3.5 pts | |
| forming pattern: 2.0 pts | |
| Capped at 10. Returns 5.0 (neutral) if empty. | |
| """ | |
| if not patterns: | |
| return 5.0 | |
| pts = {"confirmed": 3.5, "forming": 2.0, "broken": 1.0} | |
| total = sum(pts.get(p["stage"], 1.0) for p in patterns) | |
| return round(min(5.0 + total, 10.0), 2) | |