"""多时间框架对齐度评分 — Timeframe Alignment Score。 核心逻辑: - 分别判断 D1 / 4H / 1H 三个级别的方向倾向 - 统计方向一致的级别数量(对齐度 0-3) - 对齐度 3 = 三重共振,最高信心 - 对齐度 2 = 双重确认,正常交易 - 对齐度 1 = 单级别信号,降级为观察 - 对齐度 0 = 方向矛盾,不建议入场 方向判断依据(不用滞后指标,纯 PA): - 最近 N 根 K 线的高低点趋势(higher highs/higher lows vs lower highs/lower lows) - 最近动K方向 - 当前价格相对近期高低点的位置 """ from __future__ import annotations from typing import Optional import pandas as pd import numpy as np from app.config.config_loader import _get_section as _get_cfg_section # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- def _alignment_config() -> dict: """Load config from rules.yaml -> timeframe_alignment section.""" try: cfg = _get_cfg_section("timeframe_alignment") or {} except Exception: cfg = {} return { # How many candles to look back for trend detection "d1_lookback": int(cfg.get("d1_lookback", 10)), "h4_lookback": int(cfg.get("h4_lookback", 15)), "h1_lookback": int(cfg.get("h1_lookback", 20)), # Minimum swing size as % of price to count as valid swing "min_swing_pct": float(cfg.get("min_swing_pct", 1.0)), # Alignment score thresholds "min_alignment_buy_now": int(cfg.get("min_alignment_buy_now", 2)), "min_alignment_wait": int(cfg.get("min_alignment_wait", 2)), "min_alignment_observe": int(cfg.get("min_alignment_observe", 1)), # Factor weights "weight_full_alignment": float(cfg.get("weight_full_alignment", 4.0)), "weight_double_alignment": float(cfg.get("weight_double_alignment", 2.0)), "weight_no_alignment_penalty": float(cfg.get("weight_no_alignment_penalty", -3.0)), "weight_single_penalty": float(cfg.get("weight_single_penalty", -1.5)), } # --------------------------------------------------------------------------- # Single timeframe direction detection # --------------------------------------------------------------------------- def _detect_swing_points(df: pd.DataFrame, min_swing_pct: float = 1.0) -> list[dict]: """Detect significant swing highs and lows in a price series. Returns list of {"type": "high"/"low", "price": float, "index": int} sorted by index (oldest first). """ if df is None or len(df) < 5: return [] highs = df["high"].values lows = df["low"].values closes = df["close"].values n = len(df) swings = [] min_move = closes[-1] * min_swing_pct / 100 if closes[-1] > 0 else 0 # Simple swing detection: local max/min with at least 2 bars on each side for i in range(2, n - 2): # Swing high if ( highs[i] >= highs[i - 1] and highs[i] >= highs[i - 2] and highs[i] >= highs[i + 1] and highs[i] >= highs[i + 2] ): swings.append({"type": "high", "price": float(highs[i]), "index": i}) # Swing low if ( lows[i] <= lows[i - 1] and lows[i] <= lows[i - 2] and lows[i] <= lows[i + 1] and lows[i] <= lows[i + 2] ): swings.append({"type": "low", "price": float(lows[i]), "index": i}) # Filter out insignificant swings if min_move > 0 and len(swings) >= 2: filtered = [swings[0]] for s in swings[1:]: if abs(s["price"] - filtered[-1]["price"]) >= min_move: filtered.append(s) swings = filtered return sorted(swings, key=lambda x: x["index"]) def detect_timeframe_direction(df: pd.DataFrame, lookback: int = 15, min_swing_pct: float = 1.0) -> dict: """Detect the directional bias of a single timeframe. Returns: { "direction": 1 (bullish) / -1 (bearish) / 0 (neutral/unclear), "confidence": float 0-1, "reason": str, "higher_highs": bool, "higher_lows": bool, "lower_highs": bool, "lower_lows": bool, "price_position": float (0-1, where in recent range), } """ if df is None or len(df) < lookback: return { "direction": 0, "confidence": 0.0, "reason": "数据不足", "higher_highs": False, "higher_lows": False, "lower_highs": False, "lower_lows": False, "price_position": 0.5, } recent = df.tail(lookback).copy() swings = _detect_swing_points(recent, min_swing_pct) # Separate swing highs and lows swing_highs = [s for s in swings if s["type"] == "high"] swing_lows = [s for s in swings if s["type"] == "low"] # Check for higher highs / higher lows (bullish structure) higher_highs = False higher_lows = False lower_highs = False lower_lows = False if len(swing_highs) >= 2: last_two_highs = swing_highs[-2:] higher_highs = last_two_highs[1]["price"] > last_two_highs[0]["price"] lower_highs = last_two_highs[1]["price"] < last_two_highs[0]["price"] if len(swing_lows) >= 2: last_two_lows = swing_lows[-2:] higher_lows = last_two_lows[1]["price"] > last_two_lows[0]["price"] lower_lows = last_two_lows[1]["price"] < last_two_lows[0]["price"] # Price position within recent range (0 = at low, 1 = at high) range_high = float(recent["high"].max()) range_low = float(recent["low"].min()) current_close = float(recent["close"].iloc[-1]) price_range = range_high - range_low price_position = (current_close - range_low) / price_range if price_range > 0 else 0.5 # Recent candle momentum (last 3 candles net direction) last_3 = recent.tail(3) net_change = float(last_3["close"].iloc[-1]) - float(last_3["open"].iloc[0]) recent_bullish = net_change > 0 # Linear-regression slope of closes (works even without clean swing points, # e.g. strong monotonic trends where pullback-based swings never form). closes = recent["close"].values.astype(float) slope_dir = 0 slope_pct = 0.0 if len(closes) >= 3: x = np.arange(len(closes)) try: slope = np.polyfit(x, closes, 1)[0] mean_close = float(np.mean(closes)) # Normalize slope to % move per bar relative to mean price slope_pct = (slope / mean_close * 100) if mean_close > 0 else 0.0 if slope_pct > 0.15: slope_dir = 1 elif slope_pct < -0.15: slope_dir = -1 except Exception: slope_dir = 0 # Determine direction bull_score = 0 bear_score = 0 if higher_highs: bull_score += 1 if higher_lows: bull_score += 1.5 # Higher lows are more important for trend if lower_highs: bear_score += 1 if lower_lows: bear_score += 1.5 if slope_dir == 1: bull_score += 1.5 # regression trend confirms direction elif slope_dir == -1: bear_score += 1.5 if price_position > 0.65: bull_score += 0.5 elif price_position < 0.35: bear_score += 0.5 if recent_bullish: bull_score += 0.5 else: bear_score += 0.5 # Decision if bull_score >= 2.5 and bull_score > bear_score + 1: direction = 1 confidence = min(1.0, bull_score / 4.0) if higher_highs and higher_lows: reason = "高点抬高+低点抬高" elif slope_dir == 1: reason = f"趋势斜率向上({slope_pct:+.2f}%/bar)" else: reason = "结构偏多" elif bear_score >= 2.5 and bear_score > bull_score + 1: direction = -1 confidence = min(1.0, bear_score / 4.0) if lower_highs and lower_lows: reason = "高点降低+低点降低" elif slope_dir == -1: reason = f"趋势斜率向下({slope_pct:+.2f}%/bar)" else: reason = "结构偏空" else: direction = 0 confidence = 0.3 reason = "方向不明确" return { "direction": direction, "confidence": round(confidence, 2), "reason": reason, "higher_highs": higher_highs, "higher_lows": higher_lows, "lower_highs": lower_highs, "lower_lows": lower_lows, "slope_dir": slope_dir, "slope_pct": round(slope_pct, 3), "price_position": round(price_position, 3), } # --------------------------------------------------------------------------- # Multi-timeframe alignment # --------------------------------------------------------------------------- def compute_timeframe_alignment( df_d1: Optional[pd.DataFrame] = None, df_4h: Optional[pd.DataFrame] = None, df_1h: Optional[pd.DataFrame] = None, trade_direction: str = "long", ) -> dict: """Compute alignment score across D1/4H/1H timeframes. Args: df_d1: Daily kline DataFrame df_4h: 4-hour kline DataFrame df_1h: 1-hour kline DataFrame trade_direction: "long" or "short" — the intended trade direction Returns: { "alignment_score": int (0-3), "alignment_label": str, "d1_direction": dict, "h4_direction": dict, "h1_direction": dict, "aligned_timeframes": list[str], "conflicting_timeframes": list[str], "entry_allowed": bool, "max_entry_action": str, # "可即刻买入" / "等回踩" / "观察" } """ cfg = _alignment_config() target_dir = 1 if trade_direction == "long" else -1 # Detect direction for each timeframe d1_dir = detect_timeframe_direction(df_d1, cfg["d1_lookback"], cfg["min_swing_pct"]) if df_d1 is not None else None h4_dir = detect_timeframe_direction(df_4h, cfg["h4_lookback"], cfg["min_swing_pct"]) if df_4h is not None else None h1_dir = detect_timeframe_direction(df_1h, cfg["h1_lookback"], cfg["min_swing_pct"]) if df_1h is not None else None # Count alignment aligned = [] conflicting = [] timeframes = [("D1", d1_dir), ("4H", h4_dir), ("1H", h1_dir)] for tf_name, tf_result in timeframes: if tf_result is None: continue if tf_result["direction"] == target_dir: aligned.append(tf_name) elif tf_result["direction"] == -target_dir: conflicting.append(tf_name) # direction == 0 (neutral) doesn't count as aligned or conflicting alignment_score = len(aligned) # Determine entry permission based on alignment if alignment_score >= 3: alignment_label = "三重对齐" max_entry_action = "可即刻买入" entry_allowed = True elif alignment_score >= cfg["min_alignment_buy_now"]: alignment_label = "双重确认" max_entry_action = "可即刻买入" entry_allowed = True elif alignment_score >= cfg["min_alignment_observe"]: alignment_label = "单级别信号" max_entry_action = "观察" entry_allowed = True else: alignment_label = "方向矛盾" max_entry_action = "观察" entry_allowed = False # If higher timeframe (D1) is conflicting, be more conservative if "D1" in conflicting: if max_entry_action == "可即刻买入": max_entry_action = "等回踩" alignment_label += "(日线逆向)" return { "alignment_score": alignment_score, "alignment_label": alignment_label, "d1_direction": d1_dir or {"direction": 0, "confidence": 0, "reason": "无数据"}, "h4_direction": h4_dir or {"direction": 0, "confidence": 0, "reason": "无数据"}, "h1_direction": h1_dir or {"direction": 0, "confidence": 0, "reason": "无数据"}, "aligned_timeframes": aligned, "conflicting_timeframes": conflicting, "entry_allowed": entry_allowed, "max_entry_action": max_entry_action, "trade_direction": trade_direction, } # --------------------------------------------------------------------------- # Factor scoring interface # --------------------------------------------------------------------------- def alignment_factor_score(alignment_data: dict) -> tuple[float, str]: """Convert alignment data into a factor score. Returns: (score_delta, signal_label) """ cfg = _alignment_config() score_val = alignment_data.get("alignment_score", 0) label = alignment_data.get("alignment_label", "") if score_val >= 3: return cfg["weight_full_alignment"], f"多周期三重对齐({'/'.join(alignment_data.get('aligned_timeframes', []))})" elif score_val == 2: return cfg["weight_double_alignment"], f"多周期双重确认({'/'.join(alignment_data.get('aligned_timeframes', []))})" elif score_val == 1: return cfg["weight_single_penalty"], f"仅单周期支持({'/'.join(alignment_data.get('aligned_timeframes', []))})" else: conflicting = alignment_data.get("conflicting_timeframes", []) return cfg["weight_no_alignment_penalty"], f"多周期方向矛盾({'/'.join(conflicting)}冲突)" # --------------------------------------------------------------------------- # Entry action gate # --------------------------------------------------------------------------- def gate_entry_by_alignment( current_entry_action: str, alignment_data: dict, ) -> tuple[str, str]: """Downgrade entry action if alignment is insufficient. Args: current_entry_action: the entry action determined by other logic alignment_data: result from compute_timeframe_alignment Returns: (final_entry_action, reason_if_downgraded) """ max_allowed = alignment_data.get("max_entry_action", "观察") # Define action hierarchy action_rank = {"可即刻买入": 3, "即刻买入": 3, "等回踩": 2, "观察": 1} current_rank = action_rank.get(current_entry_action, 1) max_rank = action_rank.get(max_allowed, 1) if current_rank <= max_rank: return current_entry_action, "" # Downgrade reason = f"多周期对齐度不足({alignment_data.get('alignment_label', '')}), 降级为{max_allowed}" return max_allowed, reason