alphax/app/core/signal_quality.py
2026-06-01 00:16:01 +08:00

413 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""信号时效指数衰减 + 假突破/假破位概率模型(多空双向)。
## 信号时效衰减
替换硬截断的 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, ""