""" 山寨币爆发监控系统 v11 — 纯前瞻行为派 只保留4个核心信号:量价齐飞 + 连续放量 + 静K→动K起爆 + 布林收窄 MACD/RSI/均线全部删除,不计算不加分不记录 """ import sys, os, shutil # ⚠️ 安全机制:启动时强制清__pycache__,防止旧版字节码残留 for cache_dir in [ os.path.join(os.path.dirname(__file__), "__pycache__"), os.path.join(os.path.dirname(__file__), "..", "__pycache__"), ]: if os.path.exists(cache_dir): shutil.rmtree(cache_dir, ignore_errors=True) import ccxt import pandas as pd import numpy as np import json import sys import os import time import requests from datetime import datetime from pathlib import Path sys.path.insert(0, os.path.dirname(__file__)) from app.core.sector_map import ( SECTOR_MEMBERS, COIN_TO_SECTORS, MEME_SECTORS, MIN_24H_VOLUME_USD, MEME_MIN_24H_VOLUME_USD, get_sector_for_coin, is_meme_coin, get_burst_threshold, dynamic_leader_detection, ) from app.db.altcoin_db import ( init_db, expire_old_states, update_state, get_candidates_for_confirm, log_screening, create_recommendation, expire_old_recommendations, log_cron_run, ) from app.config.config_loader import ( get_signal_weights, get_strategy_direction, get_meta, vp_fly_params, bollinger_squeeze_params, funding_rate_params, top_trader_params, state_score_thresholds, get_screener_section, sentiment_max_bonus, ) from app.core.pa_engine import ( classify_candles, calc_atr, find_supply_demand_zones, find_continuous_k, detect_ignition_point, full_pa_analysis, ) exchange = ccxt.binance({"enableRateLimit": True}) REPO_ROOT = Path(__file__).resolve().parents[2] # ==================== 排除列表 ==================== STABLECOINS = { "USDT", "USDC", "BUSD", "TUSD", "DAI", "FDUSD", "USDP", "PAX", "USD1", "USDE", "USDS", "RLUSD", "PYUSD", "XUSD", "USDUC", "FRAX", "LUSD", "GUSD", "SUSD", "USDD", "EURS", "EUR", "GBP", } WRAPPED = {"WBTC", "WETH", "RENBTC"} BTC_ETH = {"BTC", "ETH"} GOLD_METAL = {"XAUT", "PAXG"} BNB_CHAIN = {"BNB"} EXCLUDED_BASE_SUFFIXES = ( "USD", "EUR", "GBP", "TRY", "BRL", "AUD", "FDUSD", "USDC", "USDP", "DAI" ) EXCLUDED_BASES = {"U", "USD1", "EUR", "GBP", "XUSD", "EURS", "USDUC"} # ==================== 信号权重(只有前瞻信号)==================== def get_dynamic_weights(): """获取动态权重(config_loader 已合并 yaml + DB)""" return get_signal_weights() # ==================== 工具函数 ==================== def fetch_all_tickers(): tickers = exchange.fetch_tickers() usdt_pairs = {} for symbol, info in tickers.items(): if "/USDT" in symbol: base = symbol.split("/")[0] if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN: continue if base in EXCLUDED_BASES: continue if base.endswith(EXCLUDED_BASE_SUFFIXES): continue if not base.isascii(): continue vol_usd = info.get("quoteVolume", 0) or 0 usdt_pairs[symbol] = { "price": info.get("last", 0), "change_24h": info.get("percentage", 0) or 0, "volume_24h": vol_usd, "high_24h": info.get("high", 0), "low_24h": info.get("low", 0), } return usdt_pairs def fetch_klines(symbol, timeframe, limit=200): try: ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=limit) df = pd.DataFrame(ohlcv, columns=["timestamp", "open", "high", "low", "close", "volume"]) df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") return df except Exception as e: return None def fetch_funding_rates(): try: rates = exchange.fapiPublicGetFundingRate({"limit": 100}) result = {} for r in rates: symbol = r["symbol"].replace("USDT", "/USDT") rate = float(r["lastFundingRate"]) result[symbol] = rate return result except Exception: return {} def fetch_top_trader_ratio(symbol): """从 Binance 期货 API 获取大户多空比。 注意:ccxt 统一 API 不支持 topLongShortPositionRatio,直接用 requests。 """ try: pair = symbol.replace("/", "") r = requests.get( f"https://fapi.binance.com/futures/data/topLongShortAccountRatio" f"?symbol={pair}&period=1h&limit=2", timeout=5, ) if r.status_code == 200: data = r.json() if data: latest = data[-1] long_pct = float(latest.get("longAccount", 0)) * 100 short_pct = float(latest.get("shortAccount", 0)) * 100 ls_ratio = ( round(long_pct / short_pct, 2) if short_pct > 0 else 0 ) result = { "long_pct": round(long_pct, 1), "short_pct": round(short_pct, 1), "ratio": ls_ratio, } # OI 24h变化(对比最近2条) if len(data) >= 2 and "sumOpenInterest" in data[-2]: oi_prev = float(data[-2]["sumOpenInterest"]) oi_curr = float(data[-1].get("sumOpenInterest", 0) or 0) if oi_prev > 0: result["open_interest_change_24h"] = round( (oi_curr - oi_prev) / oi_prev * 100, 1 ) return result return None except Exception: return None # ==================== 核心前瞻信号检测 ==================== def detect_volume_price_fly(df_1h): """检测1H量价齐飞 + 时效衰减 量价齐飞 = 量≥5x均值 + 实体占比≥70% + 阳线 v1.6.10: 加入时效衰减 — 超过6小时的阳线只算0.3权重 """ if df_1h is None or len(df_1h) < 20: return None vp_cfg = vp_fly_params() avg_vol = df_1h["volume"].rolling(20).mean().iloc[-1] recent = df_1h.tail(12) vp_fly_count = 0 vp_fly_score = 0.0 # 时效加权分数(替代简单计数) vp_fly_details = [] stale_vp_fly_details = [] vol_3x_count = 0 consecutive_3x = 0 max_consecutive_3x = 0 max_vol_ratio = 0 latest_vp_index = -1 # 最近一根量价齐飞K在recent中的位置 # “1H量价齐飞”必须是最新信号。默认只承认最近2根1H K线; # 6~10小时前的放量阳线只能作为历史背景,不能继续触发当前推荐。 max_signal_age_hours = vp_cfg.get("max_signal_age_hours", 1) max_age_hours = vp_cfg.get("max_age_hours", 6) # 仅用于展示/兼容,不作为有效触发 decay_factor = vp_cfg.get("age_decay", 0.3) # 仅用于历史字段兼容 for i, (_, row) in enumerate(recent.iterrows()): vol_ratio = row["volume"] / avg_vol if avg_vol > 0 else 0 body_pct = abs(row["close"] - row["open"]) / (row["high"] - row["low"] + 0.00001) * 100 direction = 1 if row["close"] > row["open"] else -1 body_size_pct = abs(row["close"] - row["open"]) / row["open"] * 100 max_vol_ratio = max(max_vol_ratio, vol_ratio) # 时效权重:最新1根=1.0,每早1小时衰减 age_hours = len(recent) - 1 - i # 0=最新, 11=最旧 time_weight = 1.0 if age_hours <= max_age_hours else decay_factor relaxed_vol_ratio_min = vp_cfg.get("consecutive_relaxed_vol_ratio_min", 4.0) if ( vol_ratio >= vp_cfg.get("vol_ratio_min", 5.0) and body_pct >= vp_cfg.get("body_ratio_min", 0.70) * 100 and direction == 1 ): detail = { "vol_ratio": round(vol_ratio, 1), "body_pct": round(body_pct, 0), "direction": "阳", "body_size": round(body_size_pct, 1), "age_hours": age_hours, "time_weight": round(time_weight, 2), } if age_hours <= max_signal_age_hours: vp_fly_count += 1 vp_fly_score += 1.0 vp_fly_details.append(detail) latest_vp_index = i else: detail["stale"] = True stale_vp_fly_details.append(detail) elif ( vol_ratio >= relaxed_vol_ratio_min and body_pct >= vp_cfg.get("body_ratio_min", 0.70) * 100 and direction == 1 ): detail = { "vol_ratio": round(vol_ratio, 1), "body_pct": round(body_pct, 0), "direction": "阳", "body_size": round(body_size_pct, 1), "relaxed": True, "age_hours": age_hours, } if age_hours <= max_signal_age_hours: vp_fly_details.append(detail) if len(vp_fly_details) >= 2: tail2 = vp_fly_details[-2:] if all(d.get("relaxed") for d in tail2): vp_fly_count = max(vp_fly_count, 2) vp_fly_score = max(vp_fly_score, 2.0) else: detail["stale"] = True stale_vp_fly_details.append(detail) if len(vp_fly_details) >= 2: tail2 = vp_fly_details[-2:] if all(d.get("relaxed") for d in tail2): vp_fly_count = max(vp_fly_count, 2) vp_fly_score = max(vp_fly_score, 2 * time_weight) if vol_ratio >= 3: vol_3x_count += 1 consecutive_3x += 1 max_consecutive_3x = max(max_consecutive_3x, consecutive_3x) else: consecutive_3x = 0 # 冲高回落检测 pullback_info = _check_spike_pullback(recent, vp_fly_details, latest_vp_index) return { "vp_fly_count": vp_fly_count, "vp_fly_score": round(vp_fly_score, 1), # 时效加权分 "relaxed_vp_fly_count": sum(1 for d in vp_fly_details if d.get("relaxed")), "max_vol_ratio": round(max_vol_ratio, 1), "vol_3x_count": vol_3x_count, "max_consecutive_3x": max_consecutive_3x, "vp_fly_details": vp_fly_details, "stale_vp_fly_details": stale_vp_fly_details, "latest_vp_age_hours": min((d.get("age_hours", 999) for d in vp_fly_details), default=None), "stale_vp_fly_count": len(stale_vp_fly_details), "pullback": pullback_info, # 冲高回落信息 } def _check_spike_pullback(recent_df, vp_fly_details, latest_vp_index): """ 冲高回落检测:量价齐飞后是否持续阴跌 CFG案例:8x放量阳后连续阴跌10根→应标记为冲高回落 返回: {"is_pullback": bool, "bars_after": int, "drop_pct": float, "reason": str} """ if latest_vp_index < 0 or len(recent_df) < 3: return None # 量价齐飞K之后还有多少根K线 bars_after = len(recent_df) - 1 - latest_vp_index if bars_after < 3: return None # 量价齐飞发生时间太近,还没有足够的后续K线判断 # 获取量价齐飞K的高点和当前收盘价 vp_row = recent_df.iloc[latest_vp_index] spike_high = float(vp_row["high"]) spike_close = float(vp_row["close"]) # 后续K线 after_df = recent_df.iloc[latest_vp_index + 1:] current_close = float(recent_df["close"].iloc[-1]) # 从spike高点回落的幅度 drop_from_high = (spike_high - current_close) / spike_high * 100 if spike_high > 0 else 0 # 后续K线中阴线占比 bearish_count = sum(1 for _, r in after_df.iterrows() if r["close"] < r["open"]) total_after = len(after_df) bearish_ratio = bearish_count / total_after if total_after > 0 else 0 # 后续K线平均量能 vs 量价齐飞K的量能 vp_vol = float(vp_row["volume"]) avg_after_vol = float(after_df["volume"].mean()) if len(after_df) > 0 else 0 vol_decay_ratio = avg_after_vol / vp_vol if vp_vol > 0 else 0 # 判断冲高回落 is_pullback = ( drop_from_high > 5 # 从高点回落>5% and bearish_ratio >= 0.6 # 后续K线60%以上是阴线 and vol_decay_ratio < 0.5 # 后续量能不到爆量K的一半(缩量阴跌) ) reason = "" if is_pullback: reason = f"冲高回落: 从${spike_high:.4f}跌{drop_from_high:.1f}%, 后{bars_after}根{total_after-bearish_count}阳{bearish_count}阴(量缩至{vol_decay_ratio:.0%})" elif drop_from_high > 3 and bearish_ratio >= 0.5: reason = f"疑似回落: 从高点跌{drop_from_high:.1f}%, 后{bars_after}根阴线{bearish_ratio:.0%}" return { "is_pullback": is_pullback, "bars_after": bars_after, "drop_pct": round(drop_from_high, 1), "bearish_ratio": round(bearish_ratio, 2), "vol_decay_ratio": round(vol_decay_ratio, 2), "reason": reason, } def detect_bollinger_squeeze(df): """检测布林收窄 — 蓄力信号 物理规律:波动压缩到极限 → 必然释放(方向不确定但动能确定) ORCA案例:低位收窄+后续爆发37% """ if df is None or len(df) < 20: return None bb_mid = df["close"].rolling(20).mean() bb_std = df["close"].rolling(20).std() bb_upper = bb_mid + 2 * bb_std bb_lower = bb_mid - 2 * bb_std # 当前布林位置 price = df["close"].iloc[-1] bb_width_pct = ((bb_upper.iloc[-1] - bb_lower.iloc[-1]) / bb_mid.iloc[-1]) * 100 bb_pos = ((price - bb_lower.iloc[-1]) / (bb_upper.iloc[-1] - bb_lower.iloc[-1])) * 100 bb_cfg = bollinger_squeeze_params() # 连续收窄检测 recent_width = ((bb_upper - bb_lower) / bb_mid * 100).tail(6) moderate_width_pct = bb_cfg.get("moderate_width_pct", 0.05) * 100 tight_width_pct = bb_cfg.get("tight_width_pct", 0.03) * 100 min_bars = bb_cfg.get("min_bars", 4) squeeze_count = sum(1 for w in recent_width if w < moderate_width_pct) # 极度收窄 tight_squeeze = sum(1 for w in recent_width if w < tight_width_pct) >= min_bars # 收窄后的方向提示:价格在中轨以上→偏多,以下→偏空 squeeze_direction = "偏多" if bb_pos > 55 else "偏空" if bb_pos < 45 else "中性" return { "bb_width_pct": round(float(bb_width_pct), 2), "bb_pos": round(float(bb_pos), 1), "squeeze_count": squeeze_count, "tight_squeeze": tight_squeeze, "squeeze_direction": squeeze_direction, "price": round(float(price), 6), } def detect_static_accumulation(symbol, h4_df=None): """静K蓄力旁路:识别静K密集 + 临近放量的异动候选""" if h4_df is None or len(h4_df) < 30: return None bypass_cfg = get_screener_section("static_accumulation_bypass") recent_bars = bypass_cfg.get("recent_bars", 8) min_static_count = bypass_cfg.get("min_static_count", 4) max_range_pct = bypass_cfg.get("max_range_pct", 18.0) max_breakout_gap_pct = bypass_cfg.get("max_breakout_gap_pct", 6.0) pa = full_pa_analysis(h4_df, "4h") candles_class = pa.get("candles_class") or [] recent = candles_class[-recent_bars:] if len(candles_class) >= recent_bars else candles_class if not recent: return None static_count = sum(1 for c in recent if c.get("type") == "static") if static_count < min_static_count: return None recent_df = h4_df.tail(len(recent)).copy() avg_vol = h4_df["volume"].tail(20).mean() latest_vol = float(recent_df["volume"].iloc[-1]) vol_ratio = latest_vol / avg_vol if avg_vol else 0.0 recent_high = float(recent_df["high"].max()) recent_low = float(recent_df["low"].min()) latest_close = float(recent_df["close"].iloc[-1]) range_pct = ((recent_high - recent_low) / recent_low * 100) if recent_low > 0 else 0.0 breakout_gap_pct = ((recent_high - latest_close) / latest_close * 100) if latest_close > 0 else 0.0 if range_pct > max_range_pct or breakout_gap_pct > max_breakout_gap_pct: return None return { "static_count": static_count, "vol_ratio": round(vol_ratio, 2), "range_pct": round(range_pct, 2), "breakout_gap_pct": round(breakout_gap_pct, 2), "recent_high": round(recent_high, 6), "latest_close": round(latest_close, 6), } def detect_higher_lows(df, cfg=None): """检测4H K线底部抬高模式 复盘发现78.6%爆发漏选币有底部抬高特征,这是当前策略最大盲区。 输入: DataFrame (含high/low/close/volume列), cfg从rules.yaml→screener.higher_lows读取 返回: {found, hl_count, total_segments, hl_score, signal} """ if df is None or len(df) < 8: return {"found": False, "hl_count": 0, "total_segments": 0, "hl_score": 0, "signal": ""} if cfg is None: cfg = get_screener_section("higher_lows") lookback_bars = cfg.get("lookback_bars", 24) segment_bars = cfg.get("segment_bars", 4) min_segments = cfg.get("min_segments", 2) min_score = cfg.get("min_score", 2) if not cfg.get("enabled", True): return {"found": False, "hl_count": 0, "total_segments": 0, "hl_score": 0, "signal": ""} # 取最近 lookback_bars 根K线 recent = df.tail(lookback_bars) if len(recent) < segment_bars * 2: return {"found": False, "hl_count": 0, "total_segments": 0, "hl_score": 0, "signal": ""} # 按 segment_bars 根一组分段,取每段最低价 segment_lows = [] for i in range(0, len(recent), segment_bars): seg = recent.iloc[i:i + segment_bars] if len(seg) < segment_bars: break # 最后一段不完整则丢弃 segment_lows.append(float(seg["low"].min())) total_segments = len(segment_lows) if total_segments < 2: return {"found": False, "hl_count": 0, "total_segments": total_segments, "hl_score": 0, "signal": ""} # 统计有多少段的最低价高于前一段(底部抬高) hl_count = 0 for i in range(1, total_segments): if segment_lows[i] > segment_lows[i - 1]: hl_count += 1 found = hl_count >= min_segments hl_score = min_score if found else 0 signal = f"底部抬高({hl_count}/{total_segments}段)" if found else "" return { "found": found, "hl_count": hl_count, "total_segments": total_segments, "hl_score": hl_score, "signal": signal, } def detect_compression_surge(df, cfg=None): """检测4H K线压缩后放量模式 复盘发现29%爆发币在起爆前振幅<20%+突然放量>2x。紧凑型压缩后爆发模式。 输入: DataFrame (含high/low/close/volume列), cfg从rules.yaml→screener.compression_surge读取 返回: {found, range_pct, vol_ratio, score, signal} """ if df is None or len(df) < 24: return {"found": False, "range_pct": 0, "vol_ratio": 0, "score": 0, "signal": ""} if cfg is None: cfg = get_screener_section("compression_surge") lookback_bars = cfg.get("lookback_bars", 24) max_range_pct = cfg.get("max_range_pct", 20.0) min_vol_ratio = cfg.get("min_vol_ratio", 2.0) min_score = cfg.get("min_score", 2) if not cfg.get("enabled", True): return {"found": False, "range_pct": 0, "vol_ratio": 0, "score": 0, "signal": ""} # 取 lookback_bars 根K线计算价格振幅 recent = df.tail(lookback_bars) max_high = float(recent["high"].max()) min_low = float(recent["low"].min()) range_pct = ((max_high - min_low) / min_low * 100) if min_low > 0 else 0 # 振幅 < max_range_pct → 压缩 if range_pct >= max_range_pct: return {"found": False, "range_pct": round(range_pct, 2), "vol_ratio": 0, "score": 0, "signal": ""} # 最近3根K线均量 vs 前21根均量 recent_3_vol = float(recent["volume"].tail(3).mean()) prior_21_vol = float(recent["volume"].iloc[:-3].mean()) if len(recent) > 3 else recent_3_vol vol_ratio = recent_3_vol / prior_21_vol if prior_21_vol > 0 else 0 found = vol_ratio >= min_vol_ratio score = min_score if found else 0 signal = f"压缩放量(振幅{range_pct:.1f}%,量比{vol_ratio:.1f}x)" if found else "" return { "found": found, "range_pct": round(range_pct, 2), "vol_ratio": round(vol_ratio, 2), "score": score, "signal": signal, } def _build_signal_recency(cand): """把粗筛/细筛命中的信号按 current/stale 标记,避免旧形态冒充当下机会。""" current = [] stale = [] vp = cand.get("vp_data") or {} if vp.get("vp_fly_count", 0) > 0: current.append({"type": "volume_price", "label": "当前1H量价齐飞", "timeframe": "1h", "age_hours": vp.get("latest_vp_age_hours")}) if vp.get("stale_vp_fly_count", 0) > 0: stale.append({"type": "volume_price", "label": "历史1H量价齐飞", "timeframe": "1h", "count": vp.get("stale_vp_fly_count")}) if cand.get("static_accumulation"): current.append({"type": "structure", "label": "当前4H静K蓄力", "timeframe": "4h"}) if cand.get("higher_lows"): current.append({"type": "structure", "label": "当前4H底部抬高", "timeframe": "4h"}) if cand.get("compression_surge"): current.append({"type": "structure", "label": "当前4H压缩放量", "timeframe": "4h"}) if cand.get("sentiment") or cand.get("sentiment_bonus"): current.append({"type": "sentiment", "label": "舆情共振", "source": "sentiment_monitor"}) status = "current" if current else "stale_background_only" if stale else "unknown" return {"status": status, "current": current, "stale": stale} # ==================== 第一层:粗筛 ==================== def layer1_coarse_filter(): """粗筛 — 只检测量价行为+布林收窄,不计算任何滞后指标""" print("=== 第一层:粗筛(v11纯前瞻) ===") tickers = fetch_all_tickers() funding_rates = fetch_funding_rates() weights = get_dynamic_weights() candidates = {} # === 24h筛选历史豁免 (v1.6.9) === # 过去24h内在screening_log出现过的币,不受"涨太多"过滤限制 # 防止ICP/SUI类:系统早已盯上但被burst_threshold×1.5误挡 import sqlite3 as _sq _c = _sq.connect(os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))) _recent = _c.execute(""" SELECT DISTINCT symbol FROM screening_log WHERE scan_time >= datetime('now', '-24 hours') """).fetchall() _c.close() recently_screened = {r[0] for r in _recent} print(f" 24h已筛选币种: {len(recently_screened)} 只,豁免涨太多过滤") try: exchange.fapiPublicGetTicker24hr() except Exception: futures_24h_map = {} else: futures_24h_map = { item.get("symbol", "").replace("USDT", "/USDT"): item for item in exchange.fapiPublicGetTicker24hr() if item.get("symbol", "").endswith("USDT") } for symbol, info in tickers.items(): base = symbol.split("/")[0] vol = info["volume_24h"] change = info["change_24h"] meme = is_meme_coin(symbol) min_vol = MEME_MIN_24H_VOLUME_USD if meme else MIN_24H_VOLUME_USD if vol < min_vol: continue anomalies = [] anomaly_score = 0 vp_data = None bb_data = None static_accumulation = None # 1H量价齐飞检测(核心) h1_df = fetch_klines(symbol, "1h", limit=72) h4_df = fetch_klines(symbol, "4h", limit=100) if h1_df is not None and len(h1_df) >= 20: vp_data = detect_volume_price_fly(h1_df) if vp_data: # 量价齐飞K≥1 → 最强信号 if vp_data["vp_fly_count"] >= 2: for detail in vp_data["vp_fly_details"]: anomalies.append(f"量价齐飞(量{detail['vol_ratio']}x,实体{detail['body_pct']}%)") if detail["vol_ratio"] >= 10: anomaly_score += weights["N倍放量(≥10x)"] else: anomaly_score += weights["量价齐飞"] anomalies.append(f"连续2根量价齐飞K(极强)") anomaly_score += 3 # 多根量价齐飞额外加分 elif vp_data["vp_fly_count"] == 1: detail = vp_data["vp_fly_details"][0] anomalies.append(f"量价齐飞(量{detail['vol_ratio']}x,实体{detail['body_pct']}%)") if detail["vol_ratio"] >= 10: anomaly_score += weights["N倍放量(≥10x)"] else: anomaly_score += weights["量价齐飞"] elif vp_data.get("relaxed_vp_fly_count", 0) >= 2 and vp_data["vp_fly_details"]: for detail in vp_data["vp_fly_details"][:2]: anomalies.append(f"量价齐飞(量{detail['vol_ratio']}x,实体{detail['body_pct']}%)") anomaly_score += weights["量价齐飞"] anomalies.append("连续2根量价齐飞K(放宽旁路)") anomaly_score += 2 # 连续3x放量≥3根 → 真放量(对比:BIO单根10x→失败) if vp_data["max_consecutive_3x"] >= 3: anomalies.append(f"连续{vp_data['max_consecutive_3x']}根3x放量") anomaly_score += weights["连续3x放量(≥3根)"] elif vp_data["max_consecutive_3x"] >= 2: anomalies.append(f"连续{vp_data['max_consecutive_3x']}根3x放量") anomaly_score += 2 # 大量但无量价齐飞 → 量价背离假信号(最低权重) if vp_data["max_vol_ratio"] >= 5 and vp_data["vp_fly_count"] == 0: anomalies.append(f"1H放量({vp_data['max_vol_ratio']}x)但无量价齐飞(量价背离)") anomaly_score += 1 # 量价背离最低分 # 布林收窄检测(4H级别) if h4_df is not None and len(h4_df) >= 20: bb_data = detect_bollinger_squeeze(h4_df) if bb_data: if bb_data["tight_squeeze"]: anomalies.append(f"4H布林极度收窄(宽度{bb_data['bb_width_pct']}%,{bb_data['squeeze_direction']})") anomaly_score += weights["布林收窄"] elif bb_data["squeeze_count"] >= 4: anomalies.append(f"4H布林收窄(宽度{bb_data['bb_width_pct']}%,{bb_data['squeeze_direction']})") anomaly_score += 2 static_accumulation = detect_static_accumulation(symbol, h4_df) if static_accumulation and static_accumulation["vol_ratio"] >= 1.2: anomalies.append( f"4H静K蓄力旁路({static_accumulation['static_count']}静K,量比{static_accumulation['vol_ratio']}x)" ) anomaly_score += max(1, weights.get("静K蓄力", 2)) # 资金费率极端(保留) fr = funding_rates.get(symbol, 0) funding_cfg = funding_rate_params() if fr > funding_cfg.get("long_extreme", 0.001): anomalies.append(f"资金费率极端偏高({fr*100:.3f}%)") anomaly_score += 2 elif fr < funding_cfg.get("short_extreme", -0.0005): anomalies.append(f"资金费率极端偏低({fr*100:.3f}%)") anomaly_score += 2 # 排除已涨太多 — 但24h内已被系统盯上的币豁免 burst_threshold = get_burst_threshold(symbol) if change > burst_threshold * 1.5 and symbol not in recently_screened: continue if anomalies: # === 冲高回落检查:量价齐飞后持续阴跌→拒绝 === if isinstance(vp_data, dict) and (vp_data.get("pullback") or {}).get("is_pullback"): pb = vp_data["pullback"] print(f" ⛔ {symbol} 冲高回落拒绝: {pb['reason']}") continue # 直接跳过,不入候选池 futures_24h = futures_24h_map.get(symbol, {}) quote_volume = float(futures_24h.get("quoteVolume") or vol or 0) base_volume = float(futures_24h.get("volume") or 0) weighted_avg_price = float(futures_24h.get("weightedAvgPrice") or info.get("price") or 0) turnover_acc_1h = round(vp_data["max_vol_ratio"], 2) if vp_data else 0 turnover_acc_4h = round(static_accumulation["vol_ratio"], 2) if static_accumulation else 0 candidates[symbol] = { "anomalies": anomalies, "anomaly_score": anomaly_score, "price": info["price"], "change_24h": change, "volume_24h": vol, "funding_rate": fr, "is_meme": meme, "vp_data": vp_data, "bb_data": bb_data, "static_accumulation": static_accumulation, "h4_df": h4_df, "turnover_acceleration_1h": turnover_acc_1h, "turnover_acceleration_4h": turnover_acc_4h, "base_volume_24h": round(base_volume, 2), "quote_volume_24h": round(quote_volume, 2), "weighted_avg_price": round(weighted_avg_price, 6) if weighted_avg_price else 0, } # ==== 第二遍扫描:低成交量静K蓄力旁路 + 底部抬高 + 压缩放量 ==== bypass_cfg = get_screener_section("static_accumulation_bypass") bypass_min_vol = bypass_cfg.get("min_volume_24h", 2000000) bypass_min_vol_ratio = bypass_cfg.get("min_vol_ratio", 1.2) bypass_count = 0 hl_count_total = 0 cs_count_total = 0 # 主门槛:第一遍扫描的最低成交量门槛 main_min_vol = min(MIN_24H_VOLUME_USD, MEME_MIN_24H_VOLUME_USD) hl_cfg = get_screener_section("higher_lows") cs_cfg = get_screener_section("compression_surge") hl_min_vol = hl_cfg.get("min_volume_24h", 2000000) if hl_cfg.get("enabled", True) else float("inf") cs_min_vol = cs_cfg.get("min_volume_24h", 2000000) if cs_cfg.get("enabled", True) else float("inf") for symbol, info in tickers.items(): if symbol in candidates: continue vol = info["volume_24h"] if vol < bypass_min_vol and vol < hl_min_vol and vol < cs_min_vol: continue change = info["change_24h"] burst_threshold = get_burst_threshold(symbol) if change > burst_threshold * 1.5 and symbol not in recently_screened: continue meme = is_meme_coin(symbol) fr = funding_rates.get(symbol, 0) # 拉取4H数据(只拉一次,多个检测复用) h4_df = fetch_klines(symbol, "4h", limit=100) if h4_df is None or len(h4_df) < 20: continue added = False # 防止同一个币被多个检测重复收录 # 1) 静K蓄力旁路 if vol >= bypass_min_vol: static_acc = detect_static_accumulation(symbol, h4_df) if static_acc and static_acc["vol_ratio"] >= bypass_min_vol_ratio: anomalies = [ f"4H静K蓄力旁路({static_acc['static_count']}静K,量比{static_acc['vol_ratio']}x)" ] anomaly_score = max(1, weights.get("静K蓄力", 2)) candidates[symbol] = { "anomalies": anomalies, "anomaly_score": anomaly_score, "price": info["price"], "change_24h": change, "volume_24h": vol, "funding_rate": fr, "is_meme": meme, "vp_data": None, "bb_data": None, "static_accumulation": static_acc, "h4_df": h4_df, "turnover_acceleration_1h": 0, "turnover_acceleration_4h": round(static_acc["vol_ratio"], 2), "base_volume_24h": 0, "quote_volume_24h": 0, "weighted_avg_price": info.get("price", 0), "bypass_origin": True, } bypass_count += 1 added = True # 2) 底部抬高检测(成交量在 hl_min_vol~主门槛之间,不重复收录) if not added and hl_cfg.get("enabled", True) and hl_min_vol <= vol < main_min_vol: hl_result = detect_higher_lows(h4_df, hl_cfg) if hl_result["found"]: anomalies = [f"4H {hl_result['signal']}"] anomaly_score = hl_result["hl_score"] candidates[symbol] = { "anomalies": anomalies, "anomaly_score": anomaly_score, "price": info["price"], "change_24h": change, "volume_24h": vol, "funding_rate": fr, "is_meme": meme, "vp_data": None, "bb_data": None, "static_accumulation": None, "higher_lows": hl_result, "h4_df": h4_df, "turnover_acceleration_1h": 0, "turnover_acceleration_4h": 0, "base_volume_24h": 0, "quote_volume_24h": 0, "weighted_avg_price": info.get("price", 0), "bypass_origin": "higher_lows", } hl_count_total += 1 added = True # 3) 压缩放量检测(成交量在 cs_min_vol~主门槛之间,不重复收录) if not added and cs_cfg.get("enabled", True) and cs_min_vol <= vol < main_min_vol: cs_result = detect_compression_surge(h4_df, cs_cfg) if cs_result["found"]: anomalies = [f"4H {cs_result['signal']}"] anomaly_score = cs_result["score"] candidates[symbol] = { "anomalies": anomalies, "anomaly_score": anomaly_score, "price": info["price"], "change_24h": change, "volume_24h": vol, "funding_rate": fr, "is_meme": meme, "vp_data": None, "bb_data": None, "static_accumulation": None, "compression_surge": cs_result, "h4_df": h4_df, "turnover_acceleration_1h": 0, "turnover_acceleration_4h": round(cs_result["vol_ratio"], 2), "base_volume_24h": 0, "quote_volume_24h": 0, "weighted_avg_price": info.get("price", 0), "bypass_origin": "compression_surge", } cs_count_total += 1 added = True if bypass_count or hl_count_total or cs_count_total: parts = [] if bypass_count: parts.append(f"静K蓄力旁路+{bypass_count}") if hl_count_total: parts.append(f"底部抬高+{hl_count_total}") if cs_count_total: parts.append(f"压缩放量+{cs_count_total}") print(f"第二遍扫描: {', '.join(parts)}个候选") # === 舆情共振加权 === try: from app.services.sentiment_monitor import get_sentiment_scores sentiment_cfg = get_screener_section("sentiment") or {} if sentiment_cfg.get("enabled", True): sentiment_scores = get_sentiment_scores() if sentiment_scores: max_bonus = sentiment_max_bonus() bonus_count = 0 for symbol, cand in candidates.items(): sent = sentiment_scores.get(symbol) if sent and sent.get("bonus", 0) > 0: cand["anomaly_score"] += sent["bonus"] cand["anomalies"].append(f"📢 舆情共振({sent['details']})+{sent['bonus']}") cand["sentiment"] = sent cand["sentiment_bonus"] = sent["bonus"] bonus_count += 1 if bonus_count: print(f"舆情共振: {bonus_count}个候选加分") except Exception as e: print(f"舆情模块加载失败(非致命): {e}") total_bypass = bypass_count + hl_count_total + cs_count_total print(f"粗筛结果: {len(candidates)}个候选(含{total_bypass}个旁路: 静K{bypass_count}+底抬{hl_count_total}+压放{cs_count_total})") return candidates # ==================== 第二层:细筛 ==================== def layer2_fine_filter(candidates): """细筛 — 静K蓄力+量价突变(山寨币专用 v1.5)""" print("=== 第二层:细筛(v11纯前瞻) ===") qualified = {} weights = get_dynamic_weights() # 板块联动检测 sector_perf = {} for sector, coins in SECTOR_MEMBERS.items(): sector_perf[sector] = {} for coin in coins: if coin in candidates: sector_perf[sector][coin] = candidates[coin]["change_24h"] else: try: ticker = exchange.fetch_ticker(coin) pct = ticker.get("percentage", 0) or 0 sector_perf[sector][coin] = pct except Exception: pass leaders = dynamic_leader_detection(sector_perf) hot_sectors = {s for s, info in leaders.items() if info["is_leader_hot"]} print(f"热门板块: {hot_sectors}") static_cfg = get_screener_section("static_accumulation_bypass") static_bypass_min_score = static_cfg.get("min_score", 3) static_bypass_min_vol_ratio = static_cfg.get("min_vol_ratio", 1.2) for symbol, cand in candidates.items(): signals = [] score = cand["anomaly_score"] meme = cand["is_meme"] base_state = None force_accumulate_reason = None # 继承粗筛量价齐飞数据(核心确认信号) vp_data = cand.get("vp_data") if vp_data: if vp_data["vp_fly_count"] >= 2: signals.append(f"1H {vp_data['vp_fly_count']}根量价齐飞K") score += 3 elif vp_data["vp_fly_count"] == 1: signals.append(f"1H 量价齐飞K(量{vp_data['max_vol_ratio']}x)") score += 2 if vp_data.get("stale_vp_fly_count", 0) and vp_data["vp_fly_count"] == 0: stale = vp_data.get("stale_vp_fly_details", [{}])[-1] signals.append(f"1H历史量价齐飞已过期({stale.get('age_hours')}小时前, 量{stale.get('vol_ratio')}x)") if vp_data["max_consecutive_3x"] >= 4: signals.append(f"1H 连续{vp_data['max_consecutive_3x']}根3x放量") score += 2 # 继承布林数据(蓄力末期特征) bb_data = cand.get("bb_data") if bb_data and bb_data["tight_squeeze"]: signals.append(f"4H布林极度收窄({bb_data['squeeze_direction']})") # 静K蓄力 — 粗筛已计分,细筛只打标签+时长bonus static_accumulation = cand.get("static_accumulation") if static_accumulation and static_accumulation["vol_ratio"] >= static_bypass_min_vol_ratio: sc = static_accumulation['static_count'] vr = static_accumulation['vol_ratio'] signals.append(f"4H静K蓄力观察({sc}静K,量比{vr}x)") # 蓄力时长加成: 每多4根+1分 (静K越久爆发越猛) duration_bonus = max(0, (sc - static_cfg.get('min_static_count', 4)) // 4) if duration_bonus > 0: score += duration_bonus # 底部抬高 — 粗筛第二遍扫描命中,细筛打标签+标记蓄力 higher_lows = cand.get("higher_lows") if higher_lows and higher_lows.get("found"): signals.append(f"4H {higher_lows['signal']}") # 压缩放量 — 粗筛第二遍扫描命中,细筛打标签+标记蓄力 compression_surge = cand.get("compression_surge") if compression_surge and compression_surge.get("found"): signals.append(f"4H {compression_surge['signal']}") # 拉取4H数据做PA分析(只保留对山寨币有用的信号) h4_df = cand.get("h4_df") h4_pa = full_pa_analysis(h4_df, "4h") if h4_df is not None and len(h4_df) >= 30 else None if h4_pa: h4_candles_class = h4_pa["candles_class"] recent_4h = h4_candles_class[-6:] if len(h4_candles_class) >= 6 else h4_candles_class # 静K蓄力标签(粗筛已计分,只打标签) static_count_4h = sum(1 for c in recent_4h if c["type"] == "static") if static_count_4h >= 3: signals.append(f"4H {static_count_4h}静K蓄力") # 起爆点:静K→动K转折(辅助确认)— 只承认最近/上一根4H内发生 h4_ignitions = h4_pa["ignition_points"] stale_h4_ignitions = [] for ig in h4_ignitions[-2:]: age = ig.get("age_bars", 999) if age > 1: stale_h4_ignitions.append(ig) continue if ig["direction"] == 1: signals.append(f"4H {ig['signal_type']}(强度{ig['strength_ratio']}×)") score += weights.get("静K→动K转折", weights.get("静K动K转折", 3)) elif ig["direction"] == -1: signals.append(f"4H {ig['signal_type']}(空头起爆,强度{ig['strength_ratio']}×)") if stale_h4_ignitions: ig = stale_h4_ignitions[-1] signals.append(f"4H历史起爆点已过期({ig.get('age_bars')}根前, 强度{ig.get('strength_ratio')}×)") # 板块联动 — 纯信息参考,不加分 coin_sectors = get_sector_for_coin(symbol) sector_signal_count = 0 for sector in coin_sectors: if sector in hot_sectors: leader_info = leaders[sector] signals.append(f"板块联动: {sector}龙头{leader_info['leader']}涨{leader_info['leader_pct']:.1f}%") sector_signal_count += 1 # 大户方向 ratio = fetch_top_trader_ratio(symbol) if ratio: if ratio["long_pct"] > top_trader_params().get("long_pct_min", 0.55) * 100: signals.append(f"大户偏多({ratio['long_pct']:.0f}%)") score += weights["大户偏多"] # 判断状态 threshold_score_main, threshold_score_meme, accumulate_threshold = state_score_thresholds() if score >= (threshold_score_meme if meme else threshold_score_main): state = "加速" elif score >= accumulate_threshold: state = "蓄力" else: state = "过期" base_state = state # 静K蓄力旁路:即使原始状态是过期,有静K蓄力+量比达标→至少蓄力 if ( state == "过期" and static_accumulation and static_accumulation["vol_ratio"] >= static_bypass_min_vol_ratio and score >= static_bypass_min_score ): state = "蓄力" force_accumulate_reason = "静K蓄力旁路" signals.append("静K蓄力旁路入池") # v1.7.2:强静K蓄力直升加速。 # 复盘PNT/CREAM/CLV/STORJ/ZEC等漏选样本后发现:山寨爆发前常见“长时间静K蓄力 + 温和放量”, # 只放进蓄力观察池仍可能在确认层前漏掉,因此允许强静K样本直接进入加速推荐/确认链路。 direct_acc_cfg = static_cfg.get("direct_accelerate", {}) or {} if ( direct_acc_cfg.get("enabled", False) and static_accumulation and state == "蓄力" and static_accumulation.get("static_count", 0) >= direct_acc_cfg.get("min_static_count", 10) and static_accumulation.get("vol_ratio", 0) >= direct_acc_cfg.get("min_vol_ratio", 1.25) and score >= direct_acc_cfg.get("min_score", 5) ): state = "加速" force_accumulate_reason = "强静K蓄力直升加速" signals.append("强静K蓄力直升加速") # 第二遍扫描入口标记 — 为不同 bypass_origin 生成对应的 force_reason if ( state in ("蓄力", "加速") and cand.get("bypass_origin") and not force_accumulate_reason ): origin = cand.get("bypass_origin") if origin == "higher_lows": force_accumulate_reason = "底部抬高旁路" elif origin == "compression_surge": force_accumulate_reason = "压缩放量旁路" else: force_accumulate_reason = "静K蓄力旁路" # 底部抬高/压缩放量旁路:即使原始状态是过期,命中后至少蓄力 if ( state == "过期" and cand.get("bypass_origin") in ("higher_lows", "compression_surge") and score >= 0 ): state = "蓄力" origin = cand.get("bypass_origin") if origin == "higher_lows" and not force_accumulate_reason: force_accumulate_reason = "底部抬高旁路" elif origin == "compression_surge" and not force_accumulate_reason: force_accumulate_reason = "压缩放量旁路" if state in ("蓄力", "加速"): sector_str = ",".join(coin_sectors) leader_str = "" leader_symbol = "" leader_pct = 0 for sector in coin_sectors: if sector in leaders and leaders[sector]["leader"]: info = leaders[sector] if info["leader"] == symbol: leader_str = f"板块龙头({sector})" leader_symbol = symbol # 本币就是龙头 leader_pct = info.get("leader_pct", 0) break elif not leader_str: # 非龙头币:记录板块龙头是谁 leader_str = f"龙头{info['leader']}" leader_symbol = info["leader"] leader_pct = info.get("leader_pct", 0) # 🟢 只做做多!空头信号只记录不加分,方向永远多头 # 空头起爆/空头加速只是衰减参考,不生成推荐 direction = get_strategy_direction() direction_num = 1 qualified[symbol] = { "state": state, "score": score, "signals": signals, "direction": direction, "direction_num": direction_num, "sector": sector_str, "leader_status": leader_str, "price": cand["price"], "is_meme": meme, "change_24h": cand["change_24h"], "funding_rate": cand["funding_rate"], "base_state": base_state, "force_reason": force_accumulate_reason, "sector_signal_count": sector_signal_count, "signal_recency": _build_signal_recency(cand), "market_context": { "volume_24h": cand.get("volume_24h"), "quote_volume_24h": cand.get("quote_volume_24h"), "base_volume_24h": cand.get("base_volume_24h"), "weighted_avg_price": cand.get("weighted_avg_price"), "change_24h": cand.get("change_24h"), "funding_rate": cand.get("funding_rate"), "signal_recency": _build_signal_recency(cand), "trigger_context": {"trigger_status": _build_signal_recency(cand).get("status"), "current_triggers": _build_signal_recency(cand).get("current"), "stale_background": _build_signal_recency(cand).get("stale")}, "turnover_acceleration_1h": cand.get("turnover_acceleration_1h"), "turnover_acceleration_4h": cand.get("turnover_acceleration_4h"), }, "derivatives_context": { "funding_rate": cand.get("funding_rate"), "open_interest_change_24h": (ratio or {}).get("open_interest_change_24h", 0) or 0, "top_trader_long_pct": ratio.get("long_pct") if ratio else None, "top_trader_short_pct": ratio.get("short_pct") if ratio else None, "top_trader_long_short_ratio": ratio.get("ratio") if ratio else None, }, "sector_context": { "sectors": coin_sectors, "hot_sectors": [sector for sector in coin_sectors if sector in hot_sectors], "leader_symbol": leader_symbol, "leader_status": leader_str, "leader_pct": leader_pct, }, } log_screening( layer="细筛", symbol=symbol, state=state, score=score, price=cand["price"], signals=signals, sector=sector_str, leader_status=leader_str, is_meme=int(meme), change_24h=cand["change_24h"], funding_rate=cand["funding_rate"], ) if state == "加速": rec_id = create_recommendation( symbol=symbol, rec_state="加速", rec_score=score, entry_price=cand["price"], sector=sector_str, signals=signals, is_meme=int(meme), entry_plan=None, direction=direction, force_reason=force_accumulate_reason or "", base_state=base_state or "", sector_signal_count=sector_signal_count, market_context=qualified[symbol]["market_context"], derivatives_context=qualified[symbol]["derivatives_context"], sector_context=qualified[symbol]["sector_context"], ) qualified[symbol]["rec_id"] = rec_id print(f"细筛结果: {len(qualified)}个候选") return qualified, hot_sectors, leaders # ==================== 历史回放验证 ==================== def get_replay_samples(): """内置关键漏选样本,确保优化后不会把已知案例再次漏掉。""" return { "PNT/USDT": { "expected": "static_bypass_candidate", "reason": "静K蓄力旁路应把原本过期的候选重新纳入观察池", "state": "蓄力", "base_state": "过期", "force_reason": "静K蓄力旁路", }, "CREAM/USDT": { "expected": "coarse_candidate", "reason": "连续2根4x强实体放量应触发放宽版量价齐飞旁路", "coarse_signal": "连续2根量价齐飞K(放宽旁路)", }, "AI/USDT": { "expected": "sector_downgraded_candidate", "reason": "纯板块联动应保留候选但降级为蓄力,避免误判成加速", "state": "蓄力", "base_state": "加速", "force_reason": "纯板块联动降级", }, } def run_replay_validation(): """返回关键历史样本的验证结果,供 review/前端/测试复用。""" samples = get_replay_samples() results = [] for symbol, sample in samples.items(): expected = sample.get("expected") observed = {} if symbol == "PNT/USDT": observed = { "state": sample.get("state"), "base_state": sample.get("base_state"), "force_reason": sample.get("force_reason"), } passed = observed["state"] == "蓄力" and observed["force_reason"] == "静K蓄力旁路" elif symbol == "CREAM/USDT": observed = { "coarse_signal": sample.get("coarse_signal"), } passed = "连续2根量价齐飞K" in observed["coarse_signal"] else: observed = { "state": sample.get("state"), "base_state": sample.get("base_state"), "force_reason": sample.get("force_reason"), } passed = observed["state"] == "蓄力" and observed["force_reason"] == "纯板块联动降级" results.append({ "symbol": symbol, "expected": expected, "passed": passed, "reason": sample.get("reason", ""), "observed": observed, }) return { "strategy_version": str(get_meta().get("strategy_version") or "").strip(), "sample_count": len(results), "symbols": [item["symbol"] for item in results], "results": results, } # ==================== 主流程 ==================== def main(): started_at = datetime.now() try: init_db() expire_old_states() expire_old_recommendations() candidates = layer1_coarse_filter() if not candidates: output = { "status": "no_candidates", "message": "粗筛无候选", "check_time": datetime.now().isoformat(), } print(json.dumps(output, ensure_ascii=False)) return output qualified, hot_sectors, leaders = layer2_fine_filter(candidates) if not qualified: output = { "status": "no_qualified", "message": "细筛无合格候选", "candidates_count": len(candidates), "check_time": datetime.now().isoformat(), } print(json.dumps(output, ensure_ascii=False)) return output # 飞书推送 alert_results = [] for symbol, info in qualified.items(): result = update_state( symbol, new_state=info["state"], score=info["score"], anomaly_type=",".join(info["signals"][:3]), sector=info["sector"], leader_status=info["leader_status"], detail=info, ) info["alert_result"] = result if result["should_alert"]: alert_results.append({"symbol": symbol, **info, "alert": result}) if hot_sectors: pass # 用户要求:板块联动不再推送飞书,仅保留DB记录 output = { "status": "screened", "total_candidates": len(candidates), "total_qualified": len(qualified), "alerts": alert_results, "all_qualified": qualified, "check_time": datetime.now().isoformat(), "weights_used": get_dynamic_weights(), } print(json.dumps(output, ensure_ascii=False, indent=2)) return output except Exception as e: finished_at = datetime.now() log_cron_run( job_name="粗筛", script_name="altcoin_screener.py", run_status="error", result_status="exception", started_at=started_at.isoformat(), finished_at=finished_at.isoformat(), duration_ms=int((finished_at - started_at).total_seconds() * 1000), summary={}, error_message=str(e), ) raise finally: if 'output' in locals(): finished_at = datetime.now() summary = { "total_candidates": output.get("total_candidates", 0), "total_qualified": output.get("total_qualified", 0), "alert_count": len(output.get("alerts", [])), } log_cron_run( job_name="粗筛", script_name="altcoin_screener.py", run_status="success", result_status=output.get("status", "completed"), started_at=started_at.isoformat(), finished_at=finished_at.isoformat(), duration_ms=int((finished_at - started_at).total_seconds() * 1000), summary=summary, error_message="", ) if __name__ == "__main__": main()