"""信号时效指数衰减 + 假突破/假破位概率模型(多空双向)。 ## 信号时效衰减 替换硬截断的 stale 判断,改为指数衰减: - 1H 信号:前 2 小时权重 1.0,之后每小时 ×0.6 - 4H 信号:前 8 小时权重 1.0,之后每 4 小时 ×0.5 - 15m 信号:前 30 分钟权重 1.0,之后每 15 分钟 ×0.5 - D1 信号:前 24 小时权重 1.0,之后每天 ×0.7 ## 假突破/假破位概率模型 事前估计突破质量(多空通用): - 成交量倍数(突破时量 vs 均量) - 突破幅度 vs ATR - 时段流动性(亚洲时段流动性薄) - 前方供需区距离 - 连续突破尝试次数(多次失败后的突破更可靠) """ from __future__ import annotations import math from datetime import datetime, timezone from typing import Optional import numpy as np import pandas as pd from app.config.config_loader import _get_section as _get_cfg_section # =========================================================================== # Part 1: 信号时效指数衰减 # =========================================================================== def _decay_config() -> dict: try: cfg = _get_cfg_section("signal_decay") or {} except Exception: cfg = {} return { # (grace_period_hours, decay_rate_per_period, period_hours) "1h": { "grace_hours": float(cfg.get("1h_grace_hours", 2)), "decay_rate": float(cfg.get("1h_decay_rate", 0.6)), "period_hours": float(cfg.get("1h_period_hours", 1)), }, "4h": { "grace_hours": float(cfg.get("4h_grace_hours", 8)), "decay_rate": float(cfg.get("4h_decay_rate", 0.5)), "period_hours": float(cfg.get("4h_period_hours", 4)), }, "15m": { "grace_hours": float(cfg.get("15m_grace_hours", 0.5)), "decay_rate": float(cfg.get("15m_decay_rate", 0.5)), "period_hours": float(cfg.get("15m_period_hours", 0.25)), }, "1d": { "grace_hours": float(cfg.get("1d_grace_hours", 24)), "decay_rate": float(cfg.get("1d_decay_rate", 0.7)), "period_hours": float(cfg.get("1d_period_hours", 24)), }, # Minimum weight below which signal is considered expired "min_weight": float(cfg.get("min_weight", 0.05)), } def compute_signal_decay(age_hours: float, timeframe: str = "1h") -> float: """Compute exponential decay weight for a signal based on its age. Args: age_hours: how many hours ago the signal fired timeframe: the signal's timeframe ("15m", "1h", "4h", "1d") Returns: weight between 0.0 and 1.0 """ cfg = _decay_config() tf_cfg = cfg.get(timeframe, cfg["1h"]) min_weight = cfg["min_weight"] grace = tf_cfg["grace_hours"] decay_rate = tf_cfg["decay_rate"] period = tf_cfg["period_hours"] if age_hours <= grace: return 1.0 # Number of decay periods elapsed after grace elapsed = age_hours - grace periods = elapsed / period if period > 0 else elapsed # Exponential decay: weight = decay_rate ^ periods weight = math.pow(decay_rate, periods) return max(min_weight, min(1.0, weight)) def apply_decay_to_score(base_score: float, age_hours: float, timeframe: str = "1h") -> float: """Apply time decay to a factor score. Args: base_score: the original score (positive or negative) age_hours: signal age in hours timeframe: signal timeframe Returns: decayed score (same sign, reduced magnitude) """ weight = compute_signal_decay(age_hours, timeframe) return round(base_score * weight, 3) def is_signal_expired(age_hours: float, timeframe: str = "1h") -> bool: """Check if a signal has decayed below minimum threshold.""" cfg = _decay_config() weight = compute_signal_decay(age_hours, timeframe) return weight <= cfg["min_weight"] def signal_freshness_label(age_hours: float, timeframe: str = "1h") -> str: """Human-readable freshness label.""" weight = compute_signal_decay(age_hours, timeframe) if weight >= 0.9: return "新鲜" elif weight >= 0.5: return "有效" elif weight >= 0.2: return "衰减中" elif weight > 0.05: return "即将过期" else: return "已过期" # =========================================================================== # Part 2: 假突破/假破位概率模型(多空双向) # =========================================================================== def _breakout_quality_config() -> dict: try: cfg = _get_cfg_section("breakout_quality") or {} except Exception: cfg = {} return { # Volume requirements "vol_ratio_strong": float(cfg.get("vol_ratio_strong", 3.0)), "vol_ratio_weak": float(cfg.get("vol_ratio_weak", 1.5)), # ATR requirements "atr_breakout_strong": float(cfg.get("atr_breakout_strong", 1.5)), "atr_breakout_weak": float(cfg.get("atr_breakout_weak", 0.5)), # Time-of-day (UTC hours for Asian session = low liquidity) "low_liquidity_start_utc": int(cfg.get("low_liquidity_start_utc", 0)), "low_liquidity_end_utc": int(cfg.get("low_liquidity_end_utc", 8)), # Prior attempts "prior_fail_lookback": int(cfg.get("prior_fail_lookback", 20)), "prior_fail_bonus_per_attempt": float(cfg.get("prior_fail_bonus_per_attempt", 8)), # Nearby zone penalty "zone_distance_close_pct": float(cfg.get("zone_distance_close_pct", 2.0)), "zone_distance_far_pct": float(cfg.get("zone_distance_far_pct", 5.0)), # Thresholds "high_quality_min": float(cfg.get("high_quality_min", 70)), "low_quality_max": float(cfg.get("low_quality_max", 40)), # Weights "weight_high_quality": float(cfg.get("weight_high_quality", 3.0)), "weight_low_quality_penalty": float(cfg.get("weight_low_quality_penalty", -4.0)), } def estimate_breakout_quality( df: pd.DataFrame, breakout_bar_index: int = -1, breakout_level: float = 0, direction: str = "long", atr: float = 0, nearby_zones: Optional[list[dict]] = None, ) -> dict: """Estimate the quality/probability of a breakout being genuine vs fake. Works for both breakouts (long) and breakdowns (short). Args: df: kline DataFrame containing the breakout bar breakout_bar_index: index of the breakout bar (-1 = latest) breakout_level: the price level being broken (resistance for long, support for short) direction: "long" (breakout above) or "short" (breakdown below) atr: pre-computed ATR (if 0, will compute from df) nearby_zones: supply/demand zones near the breakout [{type, top, btm, q_score}] Returns: { "quality_score": float (0-100, higher = more likely genuine), "quality_tier": "high" / "medium" / "low", "factors": { "volume_score": float, "magnitude_score": float, "timing_score": float, "prior_attempts_score": float, "zone_clearance_score": float, }, "fake_probability": float (0-1), "signal": str, "recommendation": str, # "可即刻买入" / "等确认" / "观察" } """ cfg = _breakout_quality_config() result = { "quality_score": 50.0, "quality_tier": "medium", "factors": {}, "fake_probability": 0.5, "signal": "", "recommendation": "等确认", } if df is None or len(df) < 20: return result # Get breakout bar if breakout_bar_index == -1: breakout_bar_index = len(df) - 1 if breakout_bar_index < 0 or breakout_bar_index >= len(df): return result bar = df.iloc[breakout_bar_index] bar_close = float(bar["close"]) bar_open = float(bar["open"]) bar_high = float(bar["high"]) bar_low = float(bar["low"]) bar_vol = float(bar["volume"]) # Compute ATR if not provided if atr <= 0: if len(df) >= 15: tr = pd.concat([ df["high"] - df["low"], abs(df["high"] - df["close"].shift(1)), abs(df["low"] - df["close"].shift(1)), ], axis=1).max(axis=1) atr = float(tr.rolling(14).mean().iloc[-1]) if atr <= 0: atr = float(df["high"].iloc[-1] - df["low"].iloc[-1]) # Average volume (20-bar) vol_window = min(20, len(df) - 1) avg_vol = float(df["volume"].iloc[max(0, breakout_bar_index - vol_window):breakout_bar_index].mean()) if avg_vol <= 0: avg_vol = float(df["volume"].mean()) # --- Factor 1: Volume (0-25 points) --- vol_ratio = bar_vol / avg_vol if avg_vol > 0 else 1 if vol_ratio >= cfg["vol_ratio_strong"]: volume_score = 25.0 elif vol_ratio >= cfg["vol_ratio_weak"]: volume_score = 10.0 + (vol_ratio - cfg["vol_ratio_weak"]) / (cfg["vol_ratio_strong"] - cfg["vol_ratio_weak"]) * 15 else: volume_score = max(0, vol_ratio / cfg["vol_ratio_weak"] * 10) # --- Factor 2: Breakout magnitude vs ATR (0-25 points) --- if direction == "long": magnitude = bar_close - breakout_level if breakout_level > 0 else bar_close - bar_open else: magnitude = breakout_level - bar_close if breakout_level > 0 else bar_open - bar_close atr_ratio = magnitude / atr if atr > 0 else 0 if atr_ratio >= cfg["atr_breakout_strong"]: magnitude_score = 25.0 elif atr_ratio >= cfg["atr_breakout_weak"]: magnitude_score = 8.0 + (atr_ratio - cfg["atr_breakout_weak"]) / (cfg["atr_breakout_strong"] - cfg["atr_breakout_weak"]) * 17 else: magnitude_score = max(0, atr_ratio / cfg["atr_breakout_weak"] * 8) # --- Factor 3: Timing / session (0-15 points) --- # Try to determine time of breakout timing_score = 10.0 # default neutral try: if "timestamp" in df.columns: ts = df["timestamp"].iloc[breakout_bar_index] if hasattr(ts, "hour"): hour_utc = ts.hour else: hour_utc = pd.Timestamp(ts).hour low_liq_start = cfg["low_liquidity_start_utc"] low_liq_end = cfg["low_liquidity_end_utc"] if low_liq_start <= hour_utc < low_liq_end: timing_score = 3.0 # Asian session = higher fake probability elif 13 <= hour_utc <= 20: timing_score = 15.0 # US session = most reliable else: timing_score = 10.0 # European session = decent except Exception: timing_score = 10.0 # --- Factor 4: Prior failed attempts (0-20 points) --- # More prior failures at this level = more reliable when it finally breaks prior_attempts = 0 lookback = min(cfg["prior_fail_lookback"], breakout_bar_index) if breakout_level > 0 and lookback > 0: for i in range(breakout_bar_index - lookback, breakout_bar_index): if i < 0: continue if direction == "long": # Count bars that touched but failed to close above level if float(df["high"].iloc[i]) >= breakout_level and float(df["close"].iloc[i]) < breakout_level: prior_attempts += 1 else: # Count bars that touched but failed to close below level if float(df["low"].iloc[i]) <= breakout_level and float(df["close"].iloc[i]) > breakout_level: prior_attempts += 1 prior_score = min(20.0, prior_attempts * cfg["prior_fail_bonus_per_attempt"]) # --- Factor 5: Zone clearance (0-15 points) --- # If there's a strong opposing zone very close, breakout is more likely to fail zone_score = 12.0 # default: no zone info = neutral-positive if nearby_zones: closest_opposing_dist = float("inf") for zone in nearby_zones: if direction == "long" and zone.get("type") == "supply": # Supply zone above = resistance zone_dist = (float(zone.get("btm", 0)) - bar_close) / bar_close * 100 if bar_close > 0 else 999 if 0 < zone_dist < closest_opposing_dist: closest_opposing_dist = zone_dist elif direction == "short" and zone.get("type") == "demand": # Demand zone below = support zone_dist = (bar_close - float(zone.get("top", 0))) / bar_close * 100 if bar_close > 0 else 999 if 0 < zone_dist < closest_opposing_dist: closest_opposing_dist = zone_dist if closest_opposing_dist < cfg["zone_distance_close_pct"]: zone_score = 2.0 # Very close opposing zone = high fake risk elif closest_opposing_dist < cfg["zone_distance_far_pct"]: zone_score = 8.0 else: zone_score = 15.0 # Clear path # --- Combine --- quality_score = volume_score + magnitude_score + timing_score + prior_score + zone_score quality_score = max(0, min(100, quality_score)) # Determine tier if quality_score >= cfg["high_quality_min"]: quality_tier = "high" recommendation = "可即刻买入" if direction == "long" else "可即刻做空" fake_prob = max(0.05, (100 - quality_score) / 100 * 0.4) elif quality_score <= cfg["low_quality_max"]: quality_tier = "low" recommendation = "观察" fake_prob = min(0.9, (100 - quality_score) / 100) else: quality_tier = "medium" recommendation = "等确认" fake_prob = (100 - quality_score) / 100 * 0.7 # Build signal text dir_label = "突破" if direction == "long" else "破位" signal_parts = [] if volume_score >= 20: signal_parts.append(f"放量{vol_ratio:.1f}x") elif volume_score < 8: signal_parts.append(f"量不足{vol_ratio:.1f}x") if magnitude_score >= 20: signal_parts.append(f"幅度{atr_ratio:.1f}ATR") elif magnitude_score < 8: signal_parts.append(f"幅度弱{atr_ratio:.1f}ATR") if prior_attempts >= 2: signal_parts.append(f"第{prior_attempts+1}次尝试") if timing_score <= 5: signal_parts.append("亚洲时段") signal = f"{dir_label}质量{'高' if quality_tier == 'high' else '低' if quality_tier == 'low' else '中'}({', '.join(signal_parts)})" if signal_parts else "" result.update({ "quality_score": round(quality_score, 1), "quality_tier": quality_tier, "factors": { "volume_score": round(volume_score, 1), "volume_ratio": round(vol_ratio, 2), "magnitude_score": round(magnitude_score, 1), "atr_ratio": round(atr_ratio, 2), "timing_score": round(timing_score, 1), "prior_attempts_score": round(prior_score, 1), "prior_attempts": prior_attempts, "zone_clearance_score": round(zone_score, 1), }, "fake_probability": round(fake_prob, 3), "signal": signal, "recommendation": recommendation, }) return result # --------------------------------------------------------------------------- # Factor scoring interface # --------------------------------------------------------------------------- def breakout_quality_factor_score(quality_data: dict) -> tuple[float, str]: """Convert breakout quality into a factor score. Returns: (score_delta, signal_label) """ cfg = _breakout_quality_config() tier = quality_data.get("quality_tier", "medium") signal = quality_data.get("signal", "") if tier == "high": return cfg["weight_high_quality"], signal elif tier == "low": return cfg["weight_low_quality_penalty"], signal else: return 0.0, ""