""" Chart pattern detection engine. Detects technical analysis patterns on kline data and returns drawing instructions for the frontend to overlay on candlestick charts. """ import logging from dataclasses import dataclass, field import numpy as np from app.models.schemas import KlineData logger = logging.getLogger(__name__) @dataclass class PatternResult: pattern_type: str name: str direction: str # "bullish", "bearish", "neutral" confidence: float # 0-100 start_idx: int end_idx: int description: str draw_lines: list[dict] = field(default_factory=list) def to_dict(self) -> dict: return { "pattern_type": self.pattern_type, "name": self.name, "direction": self.direction, "confidence": round(self.confidence, 1), "start_idx": self.start_idx, "end_idx": self.end_idx, "description": self.description, "draw_lines": self.draw_lines, } PATTERN_REGISTRY: dict[str, dict] = { "trendline_support": {"name": "支撑趋势线", "description": "连接多个低点形成上升/下降支撑线"}, "trendline_resistance": {"name": "阻力趋势线", "description": "连接多个高点形成上升/下降阻力线"}, "ascending_triangle": {"name": "上升三角形", "description": "水平阻力 + 上升支撑,看涨突破形态"}, "descending_triangle": {"name": "下降三角形", "description": "水平支撑 + 下降阻力,看跌突破形态"}, "symmetric_triangle": {"name": "对称三角形", "description": "收敛三角形,突破方向待定"}, "double_top": {"name": "双顶 (M头)", "description": "两个近似高点,看跌反转形态"}, "double_bottom": {"name": "双底 (W底)", "description": "两个近似低点,看涨反转形态"}, "head_shoulders": {"name": "头肩顶", "description": "头部高于两肩,看跌反转形态"}, "inv_head_shoulders": {"name": "头肩底", "description": "头部低于两肩,看涨反转形态"}, "channel_up": {"name": "上升通道", "description": "平行的上升支撑和阻力"}, "channel_down": {"name": "下降通道", "description": "平行的下降支撑和阻力"}, "wedge_rising": {"name": "上升楔形", "description": "收窄的上升通道,看跌"}, "wedge_falling": {"name": "下降楔形", "description": "收窄的下降通道,看涨"}, "support_level": {"name": "水平支撑", "description": "价格多次触及的水平支撑位"}, "resistance_level": {"name": "水平阻力", "description": "价格多次触及的水平阻力位"}, } def _find_local_extrema(prices: np.ndarray, order: int = 5) -> tuple[list[int], list[int]]: highs, lows = [], [] for i in range(order, len(prices) - order): if all(prices[i] >= prices[i - j] for j in range(1, order + 1)) and \ all(prices[i] >= prices[i + j] for j in range(1, order + 1)): highs.append(i) if all(prices[i] <= prices[i - j] for j in range(1, order + 1)) and \ all(prices[i] <= prices[i + j] for j in range(1, order + 1)): lows.append(i) return highs, lows def _fit_line(x_indices: list[int], y_values: list[float]) -> tuple[float, float]: if len(x_indices) < 2: return 0.0, y_values[0] if y_values else 0.0 x = np.array(x_indices, dtype=float) y = np.array(y_values, dtype=float) slope, intercept = np.polyfit(x, y, 1) return float(slope), float(intercept) def _make_line(x1: int, y1: float, x2: int, y2: float, color: str, style: str = "solid", label: str = "") -> dict: return {"x1": x1, "y1": round(y1, 2), "x2": x2, "y2": round(y2, 2), "color": color, "style": style, "label": label} def detect_trendlines(highs_arr: np.ndarray, lows_arr: np.ndarray, n: int, order: int = 5) -> list[PatternResult]: results = [] high_idxs, low_idxs = _find_local_extrema(highs_arr, order) _, low_idxs2 = _find_local_extrema(lows_arr, order) if len(low_idxs2) >= 2: vals = [float(lows_arr[i]) for i in low_idxs2] slope, intercept = _fit_line(low_idxs2, vals) y1 = intercept + slope * low_idxs2[0] extend_y2 = intercept + slope * (n - 1) direction = "bullish" if slope > 0 else "bearish" if slope < 0 else "neutral" results.append(PatternResult( pattern_type="trendline_support", name="支撑趋势线", direction=direction, confidence=65 + min(len(low_idxs2), 5) * 5, start_idx=low_idxs2[0], end_idx=n - 1, description=f"连接{len(low_idxs2)}个低点,斜率{'上升' if slope > 0 else '下降'}", draw_lines=[_make_line(low_idxs2[0], y1, n - 1, extend_y2, "#3fb950", "dashed", "支撑线")], )) if len(high_idxs) >= 2: vals = [float(highs_arr[i]) for i in high_idxs] slope, intercept = _fit_line(high_idxs, vals) y1 = intercept + slope * high_idxs[0] extend_y2 = intercept + slope * (n - 1) direction = "bullish" if slope > 0 else "bearish" results.append(PatternResult( pattern_type="trendline_resistance", name="阻力趋势线", direction=direction, confidence=65 + min(len(high_idxs), 5) * 5, start_idx=high_idxs[0], end_idx=n - 1, description=f"连接{len(high_idxs)}个高点,斜率{'上升' if slope > 0 else '下降'}", draw_lines=[_make_line(high_idxs[0], y1, n - 1, extend_y2, "#f85149", "dashed", "阻力线")], )) return results def detect_triangles(highs_arr: np.ndarray, lows_arr: np.ndarray, n: int) -> list[PatternResult]: results = [] high_idxs, low_idxs = _find_local_extrema(highs_arr, 5) _, low_idxs2 = _find_local_extrema(lows_arr, 5) if len(high_idxs) >= 2 and len(low_idxs2) >= 2: h_slope, h_int = _fit_line(high_idxs, [float(highs_arr[i]) for i in high_idxs]) l_slope, l_int = _fit_line(low_idxs2, [float(lows_arr[i]) for i in low_idxs2]) h_y1 = h_int + h_slope * high_idxs[0] h_y2 = h_int + h_slope * high_idxs[-1] l_y1 = l_int + l_slope * low_idxs2[0] l_y2 = l_int + l_slope * low_idxs2[-1] h_range = abs(h_y2 - h_y1) l_range = abs(l_y2 - l_y1) avg_price = float(np.mean(highs_arr)) threshold = avg_price * 0.003 if h_range < threshold and l_slope > 0: results.append(PatternResult( pattern_type="ascending_triangle", name="上升三角形", direction="bullish", confidence=72, start_idx=min(high_idxs[0], low_idxs2[0]), end_idx=max(high_idxs[-1], low_idxs2[-1]), description="水平阻力线 + 上升支撑线,看涨突破", draw_lines=[ _make_line(high_idxs[0], h_y1, high_idxs[-1], h_y2, "#f85149", "solid", "水平阻力"), _make_line(low_idxs2[0], l_y1, low_idxs2[-1], l_y2, "#3fb950", "solid", "上升支撑"), ], )) elif l_range < threshold and h_slope < 0: results.append(PatternResult( pattern_type="descending_triangle", name="下降三角形", direction="bearish", confidence=72, start_idx=min(high_idxs[0], low_idxs2[0]), end_idx=max(high_idxs[-1], low_idxs2[-1]), description="下降阻力线 + 水平支撑线,看跌突破", draw_lines=[ _make_line(high_idxs[0], h_y1, high_idxs[-1], h_y2, "#f85149", "solid", "下降阻力"), _make_line(low_idxs2[0], l_y1, low_idxs2[-1], l_y2, "#3fb950", "solid", "水平支撑"), ], )) elif h_slope < 0 and l_slope > 0: results.append(PatternResult( pattern_type="symmetric_triangle", name="对称三角形", direction="neutral", confidence=68, start_idx=min(high_idxs[0], low_idxs2[0]), end_idx=max(high_idxs[-1], low_idxs2[-1]), description="收敛三角形,等待方向突破", draw_lines=[ _make_line(high_idxs[0], h_y1, high_idxs[-1], h_y2, "#f85149", "solid", "下降阻力"), _make_line(low_idxs2[0], l_y1, low_idxs2[-1], l_y2, "#3fb950", "solid", "上升支撑"), ], )) return results def detect_double_patterns(highs_arr: np.ndarray, lows_arr: np.ndarray, closes: np.ndarray, n: int) -> list[PatternResult]: results = [] high_idxs, _ = _find_local_extrema(highs_arr, 5) _, low_idxs = _find_local_extrema(lows_arr, 5) if len(high_idxs) >= 2: for i in range(len(high_idxs) - 1): h1, h2 = high_idxs[i], high_idxs[i + 1] if h2 - h1 < 10: continue p1, p2 = float(highs_arr[h1]), float(highs_arr[h2]) avg_p = (p1 + p2) / 2 if abs(p1 - p2) / avg_p < 0.01: neckline = float(np.min(lows_arr[h1:h2 + 1])) results.append(PatternResult( pattern_type="double_top", name="双顶 (M头)", direction="bearish", confidence=75, start_idx=h1, end_idx=h2, description=f"两个高点 {p1:.2f}/{p2:.2f},颈线 {neckline:.2f}", draw_lines=[ _make_line(h1, p1, h2, p2, "#f85149", "solid", "双顶连线"), _make_line(h1, neckline, h2, neckline, "#d29922", "dashed", "颈线"), ], )) break if len(low_idxs) >= 2: for i in range(len(low_idxs) - 1): l1, l2 = low_idxs[i], low_idxs[i + 1] if l2 - l1 < 10: continue p1, p2 = float(lows_arr[l1]), float(lows_arr[l2]) avg_p = (p1 + p2) / 2 if abs(p1 - p2) / avg_p < 0.01: neckline = float(np.max(highs_arr[l1:l2 + 1])) results.append(PatternResult( pattern_type="double_bottom", name="双底 (W底)", direction="bullish", confidence=75, start_idx=l1, end_idx=l2, description=f"两个低点 {p1:.2f}/{p2:.2f},颈线 {neckline:.2f}", draw_lines=[ _make_line(l1, p1, l2, p2, "#3fb950", "solid", "双底连线"), _make_line(l1, neckline, l2, neckline, "#d29922", "dashed", "颈线"), ], )) break return results def detect_support_resistance(highs_arr: np.ndarray, lows_arr: np.ndarray, n: int) -> list[PatternResult]: results = [] price_range = float(np.max(highs_arr) - np.min(lows_arr)) tolerance = price_range * 0.008 high_idxs, _ = _find_local_extrema(highs_arr, 4) _, low_idxs = _find_local_extrema(lows_arr, 4) resistance_levels = [] for i in high_idxs: p = float(highs_arr[i]) found = False for lvl in resistance_levels: if abs(p - lvl["price"]) < tolerance: lvl["touches"] += 1 lvl["indices"].append(i) lvl["price"] = (lvl["price"] + p) / 2 found = True break if not found: resistance_levels.append({"price": p, "touches": 1, "indices": [i]}) for lvl in resistance_levels: if lvl["touches"] >= 2: results.append(PatternResult( pattern_type="resistance_level", name="水平阻力", direction="bearish", confidence=60 + lvl["touches"] * 8, start_idx=lvl["indices"][0], end_idx=n - 1, description=f"阻力位 {lvl['price']:.2f},触及 {lvl['touches']} 次", draw_lines=[_make_line(lvl["indices"][0], lvl["price"], n - 1, lvl["price"], "#f85149", "dotted", f"阻力 {lvl['price']:.2f}")], )) support_levels = [] for i in low_idxs: p = float(lows_arr[i]) found = False for lvl in support_levels: if abs(p - lvl["price"]) < tolerance: lvl["touches"] += 1 lvl["indices"].append(i) lvl["price"] = (lvl["price"] + p) / 2 found = True break if not found: support_levels.append({"price": p, "touches": 1, "indices": [i]}) for lvl in support_levels: if lvl["touches"] >= 2: results.append(PatternResult( pattern_type="support_level", name="水平支撑", direction="bullish", confidence=60 + lvl["touches"] * 8, start_idx=lvl["indices"][0], end_idx=n - 1, description=f"支撑位 {lvl['price']:.2f},触及 {lvl['touches']} 次", draw_lines=[_make_line(lvl["indices"][0], lvl["price"], n - 1, lvl["price"], "#3fb950", "dotted", f"支撑 {lvl['price']:.2f}")], )) return results def detect_channels(highs_arr: np.ndarray, lows_arr: np.ndarray, n: int) -> list[PatternResult]: results = [] high_idxs, _ = _find_local_extrema(highs_arr, 5) _, low_idxs = _find_local_extrema(lows_arr, 5) if len(high_idxs) >= 2 and len(low_idxs) >= 2: h_slope, h_int = _fit_line(high_idxs, [float(highs_arr[i]) for i in high_idxs]) l_slope, l_int = _fit_line(low_idxs, [float(lows_arr[i]) for i in low_idxs]) slope_diff = abs(h_slope - l_slope) avg_slope = (abs(h_slope) + abs(l_slope)) / 2 if avg_slope > 0 and slope_diff / avg_slope < 0.4: start = min(high_idxs[0], low_idxs[0]) end = max(high_idxs[-1], low_idxs[-1]) h_y1, h_y2 = h_int + h_slope * start, h_int + h_slope * end l_y1, l_y2 = l_int + l_slope * start, l_int + l_slope * end if h_slope > 0 and l_slope > 0: ptype, pname, direction = "channel_up", "上升通道", "bullish" elif h_slope < 0 and l_slope < 0: ptype, pname, direction = "channel_down", "下降通道", "bearish" else: return results width_start = abs(h_y1 - l_y1) width_end = abs(h_y2 - l_y2) if width_end < width_start * 0.7: ptype = "wedge_rising" if h_slope > 0 else "wedge_falling" pname = "上升楔形" if h_slope > 0 else "下降楔形" direction = "bearish" if h_slope > 0 else "bullish" results.append(PatternResult( pattern_type=ptype, name=pname, direction=direction, confidence=70, start_idx=start, end_idx=end, description=f"{pname},通道宽度 {width_start:.2f} → {width_end:.2f}", draw_lines=[ _make_line(start, h_y1, end, h_y2, "#f85149", "solid", "上轨"), _make_line(start, l_y1, end, l_y2, "#3fb950", "solid", "下轨"), ], )) return results def detect_patterns(klines: list[KlineData], enabled_patterns: list[str] | None = None) -> list[dict]: if len(klines) < 30: return [] closes = np.array([k.close for k in klines]) highs = np.array([k.high for k in klines]) lows = np.array([k.low for k in klines]) n = len(klines) all_patterns: list[PatternResult] = [] all_types = enabled_patterns or list(PATTERN_REGISTRY.keys()) need_trendline = any(t.startswith("trendline") for t in all_types) need_triangle = any(t.endswith("triangle") for t in all_types) need_double = any(t.startswith("double") for t in all_types) need_sr = any(t.endswith("level") for t in all_types) need_channel = any(t.startswith("channel") or t.startswith("wedge") for t in all_types) if need_trendline: all_patterns.extend(detect_trendlines(highs, lows, n)) if need_triangle: all_patterns.extend(detect_triangles(highs, lows, n)) if need_double: all_patterns.extend(detect_double_patterns(highs, lows, closes, n)) if need_sr: all_patterns.extend(detect_support_resistance(highs, lows, n)) if need_channel: all_patterns.extend(detect_channels(highs, lows, n)) filtered = [p for p in all_patterns if p.pattern_type in all_types] filtered.sort(key=lambda p: p.confidence, reverse=True) return [p.to_dict() for p in filtered] def get_pattern_registry() -> list[dict]: return [{"type": k, **v} for k, v in PATTERN_REGISTRY.items()]