""" 山寨币爆发监控系统 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, timedelta 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, 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, ) from app.core.opportunity_funnel import ( build_screening_detail, discovery_source_types, quality_filter_reasons, universe_gate_reason, ) from app.core.signal_taxonomy import signal_codes as build_signal_codes 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 = {} universe_exclusions = [] for symbol, info in tickers.items(): if "/USDT" in symbol: base = symbol.split("/")[0] vol_usd = info.get("quoteVolume", 0) or 0 if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN: reason = universe_gate_reason(base, vol_usd, 0, symbol=symbol) or {"reason_code": "excluded_base", "reason_label": "排除基础资产"} universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, **reason}) continue if base in EXCLUDED_BASES: universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "invalid_pair", "reason_label": "交易对异常"}) continue if base.endswith(EXCLUDED_BASE_SUFFIXES): universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "invalid_pair", "reason_label": "交易对异常"}) continue if not base.isascii(): universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "non_ascii", "reason_label": "非标准交易对"}) continue 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), } fetch_all_tickers.last_universe_exclusions = universe_exclusions 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 _log_universe_exclusions(exclusions, max_logs=120): """把交易宇宙过滤结果写入链路日志,避免页面看不到第一道漏斗。""" logged = 0 for item in (exclusions or [])[:max_logs]: detail = build_screening_detail( layer="universe_gate", state="过期", detail={ "reason_code": item.get("reason_code", ""), "reason_label": item.get("reason_label", ""), "volume_24h": item.get("volume_24h", 0), "candidate_stage": "universe_gate", }, ) log_screening( layer="universe_gate", symbol=item.get("symbol", ""), state="过期", score=0, price=item.get("price", 0) or 0, signals=[item.get("reason_label", "交易宇宙过滤")], change_24h=item.get("change_24h", 0) or 0, funding_rate=0, detail=detail, ) logged += 1 return logged # ==================== 第一层:粗筛 ==================== def layer1_coarse_filter(): """粗筛 — 只检测量价行为+布林收窄,不计算任何滞后指标""" print("=== 第一层:粗筛(v11纯前瞻) ===") tickers = fetch_all_tickers() universe_exclusions = list(getattr(fetch_all_tickers, "last_universe_exclusions", []) or []) excluded_symbols = {item.get("symbol", "") for item in universe_exclusions} funding_rates = fetch_funding_rates() weights = get_dynamic_weights() candidates = {} # === 24h筛选历史豁免 (v1.6.9) === # 过去24h内在screening_log出现过的币,不受"涨太多"过滤限制 # 防止ICP/SUI类:系统早已盯上但被burst_threshold×1.5误挡 from app.db.schema import get_conn as _get_conn _c = _get_conn() _recent = _c.execute(""" SELECT DISTINCT symbol FROM screening_log WHERE scan_time >= %s """, ((datetime.now() - timedelta(hours=24)).isoformat(),)).fetchall() _c.close() recently_screened = {r["symbol"] 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 # 第一道漏斗:把明确不可交易/太低成交额的资产写成独立阶段,研发侧可审计, # 但不让它们进入后续机会链路。 low_turnover_threshold = min(v for v in [main_min_vol, bypass_min_vol, hl_min_vol, cs_min_vol] if v != float("inf")) for symbol, info in tickers.items(): if symbol in candidates or symbol in excluded_symbols: continue if float(info.get("volume_24h") or 0) < low_turnover_threshold: gate = universe_gate_reason(symbol.split("/")[0], info.get("volume_24h") or 0, low_turnover_threshold, symbol=symbol) if gate: universe_exclusions.append({ "symbol": symbol, "base": symbol.split("/")[0], "price": info.get("price", 0) or 0, "volume_24h": info.get("volume_24h", 0) or 0, "change_24h": info.get("change_24h", 0) or 0, **gate, }) excluded_symbols.add(symbol) universe_logged = _log_universe_exclusions(universe_exclusions) 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)}个候选(宇宙过滤{len(universe_exclusions)}个,记录{universe_logged}个;含{total_bypass}个旁路: 静K{bypass_count}+底抬{hl_count_total}+压放{cs_count_total})") for symbol, cand in candidates.items(): signals = cand.get("anomalies", []) log_screening( layer="粗筛", symbol=symbol, state="候选", score=cand.get("anomaly_score", 0), price=cand.get("price", 0), signals=signals, is_meme=int(cand.get("is_meme") or 0), change_24h=cand.get("change_24h", 0), funding_rate=cand.get("funding_rate", 0), detail=build_screening_detail( layer="粗筛", state="候选", signals=signals, candidate=cand, detail={ "candidate_stage": "discovery_candidate", "volume_24h": cand.get("volume_24h", 0), "turnover_acceleration_1h": cand.get("turnover_acceleration_1h", 0), "turnover_acceleration_4h": cand.get("turnover_acceleration_4h", 0), "signal_recency": _build_signal_recency(cand), "bypass_origin": cand.get("bypass_origin", ""), "source_types": discovery_source_types(cand), "signal_codes": build_signal_codes(signals), }, ), ) layer1_coarse_filter.last_funnel_meta = { "universe_gate_count": len(universe_exclusions), "universe_gate_logged": universe_logged, } return candidates # ==================== 第二层:细筛 ==================== def layer2_fine_filter(candidates): """细筛 — 静K蓄力+量价突变(山寨币专用 v1.5)""" print("=== 第二层:细筛(v11纯前瞻) ===") qualified = {} rejected_count = 0 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 = "压缩放量旁路" quality = quality_filter_reasons(cand, int(score or 0), accumulate_threshold, signals) 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, }, "candidate_stage": "qualified_candidate", "next_stage": "trade_confirm", } 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"], detail=build_screening_detail( layer="细筛", state=state, signals=signals, candidate={**cand, "signals": signals}, detail={ "candidate_stage": "qualified_candidate", "quality_reason_codes": quality["codes"], "quality_reason_labels": quality["labels"], "base_state": base_state, "force_reason": force_accumulate_reason or "", "sector_signal_count": sector_signal_count, "signal_recency": _build_signal_recency(cand), "signal_codes": build_signal_codes(signals), "next_stage": "trade_confirm", }, ), ) if state == "加速": # 初筛只负责机会发现和候选入池。交易推荐必须由确认层生成完整 entry_plan 后写入 recommendation, # 避免把“涨幅榜共性候选/观察池”污染成已推荐交易样本。 qualified[symbol]["next_stage"] = "trade_confirm" else: rejected_count += 1 reject_signals = signals or cand.get("anomalies", []) log_screening( layer="细筛", symbol=symbol, state=state, score=score, price=cand.get("price", 0), signals=reject_signals, sector=",".join(coin_sectors), leader_status="", is_meme=int(meme), change_24h=cand.get("change_24h", 0), funding_rate=cand.get("funding_rate", 0), detail=build_screening_detail( layer="细筛", state=state, signals=reject_signals, candidate={**cand, "signals": reject_signals}, detail={ "candidate_stage": "rejected_candidate", "reject_reason_codes": quality["codes"] or ["low_score"], "reject_reason_labels": quality["labels"] or ["评分不足"], "score": score, "threshold": accumulate_threshold, "base_state": base_state, "signal_recency": _build_signal_recency(cand), "signal_codes": build_signal_codes(reject_signals), }, ), ) layer2_fine_filter.last_funnel_meta = {"quality_rejected_count": rejected_count} print(f"细筛结果: {len(qualified)}个候选,淘汰{rejected_count}个") 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 _emit_output(output, compact: bool = False): if compact: print(json.dumps(output, ensure_ascii=False)) else: print(json.dumps(output, ensure_ascii=False, indent=2)) def main(compact: bool = False): started_at = datetime.now() try: init_db() expire_old_states() expire_old_recommendations() candidates = layer1_coarse_filter() funnel_meta = getattr(layer1_coarse_filter, "last_funnel_meta", {}) if not candidates: output = { "status": "no_candidates", "message": "粗筛无候选", "universe_gate_count": funnel_meta.get("universe_gate_count", 0), "check_time": datetime.now().isoformat(), } _emit_output(output, compact=compact) return output qualified, hot_sectors, leaders = layer2_fine_filter(candidates) fine_meta = getattr(layer2_fine_filter, "last_funnel_meta", {}) if not qualified: output = { "status": "no_qualified", "message": "细筛无合格候选", "candidates_count": len(candidates), "universe_gate_count": funnel_meta.get("universe_gate_count", 0), "quality_rejected_count": fine_meta.get("quality_rejected_count", 0), "check_time": datetime.now().isoformat(), } _emit_output(output, compact=compact) 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), "universe_gate_count": funnel_meta.get("universe_gate_count", 0), "quality_rejected_count": fine_meta.get("quality_rejected_count", 0), "alerts": alert_results, "all_qualified": qualified, "check_time": datetime.now().isoformat(), "weights_used": get_dynamic_weights(), } _emit_output(output, compact=compact) 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), "universe_gate_count": output.get("universe_gate_count", 0), "quality_rejected_count": output.get("quality_rejected_count", 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__": import argparse parser = argparse.ArgumentParser(description="AlphaX Agent | Crypto 粗筛/细筛主流程") parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON,便于脚本消费") args = parser.parse_args() main(compact=args.compact)