alphax/app/services/altcoin_screener.py
2026-05-24 20:44:22 +08:00

1694 lines
72 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.

"""
山寨币爆发监控系统 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]
BINANCE_SPOT_BASE_URL = os.getenv("ALPHAX_BINANCE_SPOT_BASE_URL", "https://api.binance.com").rstrip("/")
# ==================== 排除列表 ====================
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"}
GOLD_METAL = {"XAUT", "PAXG"}
MAJOR_BASES = {"BTC", "ETH", "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_spot_24h_tickers():
"""Fetch spot 24h tickers without ccxt market loading.
ccxt.fetch_tickers() can call Binance futures exchangeInfo as part of
load_markets(), which is exactly the endpoint most likely to be IP-banned.
The public spot 24h endpoint is enough for our broad universe scan.
"""
resp = requests.get(f"{BINANCE_SPOT_BASE_URL}/api/v3/ticker/24hr", timeout=15)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
return {}
tickers = {}
for item in data:
raw_symbol = str(item.get("symbol") or "")
if not raw_symbol.endswith("USDT"):
continue
base = raw_symbol[:-4]
if not base:
continue
close_time = item.get("closeTime")
ticker_dt = ""
try:
ticker_dt = datetime.utcfromtimestamp(float(close_time) / 1000).isoformat()
except Exception:
ticker_dt = ""
tickers[f"{base}/USDT"] = {
"last": float(item.get("lastPrice") or 0),
"percentage": float(item.get("priceChangePercent") or 0),
"quoteVolume": float(item.get("quoteVolume") or 0),
"high": float(item.get("highPrice") or 0),
"low": float(item.get("lowPrice") or 0),
"datetime": ticker_dt,
}
return tickers
def fetch_all_tickers():
tickers = _fetch_spot_24h_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
ticker_dt = info.get("datetime")
if ticker_dt:
try:
ticker_time = datetime.fromisoformat(str(ticker_dt).replace("Z", "+00:00")).replace(tzinfo=None)
if datetime.utcnow() - ticker_time > timedelta(hours=36):
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "stale_ticker", "reason_label": "行情数据过旧"})
continue
except Exception:
pass
if base in STABLECOINS or base in WRAPPED or base in GOLD_METAL:
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 = []
if cand.get("top_gainer_24h"):
current.append({
"type": "cex_top_gainer",
"label": "当前24h强势榜异动",
"timeframe": "24h",
"change_24h": cand.get("change_24h", 0),
})
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 _is_top_gainer_candidate(symbol, info, *, min_volume=None, threshold=None):
"""把涨幅榜强势本身纳入发现层,避免已启动币被粗筛静默跳过。"""
volume = float((info or {}).get("volume_24h") or 0)
change = float((info or {}).get("change_24h") or 0)
if min_volume is None:
min_volume = MEME_MIN_24H_VOLUME_USD if is_meme_coin(symbol) else MIN_24H_VOLUME_USD
if threshold is None:
threshold = get_burst_threshold(symbol) * 1.5
return volume >= float(min_volume or 0) and change >= float(threshold or 0)
def _top_gainer_signal(symbol, change, volume):
return f"24h强势榜异动({float(change or 0):.1f}%,成交额{float(volume or 0)/1_000_000:.1f}M)"
def _static_bypass_resonance(cand, *, static_cfg, sector_signal_count=0, top_trader_ratio=None, vp_data=None):
"""Return resonance signals that make a static-K bypass worth confirming."""
signals = []
vp_data = vp_data or cand.get("vp_data") or {}
if vp_data.get("vp_fly_count", 0) > 0:
signals.append("current_vp_fly")
if vp_data.get("max_consecutive_3x", 0) >= int(static_cfg.get("min_consecutive_3x", 3)):
signals.append("consecutive_volume")
static_acc = cand.get("static_accumulation") or {}
if float(static_acc.get("vol_ratio") or 0) >= float(static_cfg.get("strong_vol_ratio", 2.0)):
signals.append("strong_static_volume")
if int(sector_signal_count or 0) > 0:
signals.append("sector_rotation")
if top_trader_ratio and float(top_trader_ratio.get("long_pct") or 0) >= float(static_cfg.get("min_top_trader_long_pct", 65)):
signals.append("top_trader_long")
if cand.get("top_gainer_24h") and float(cand.get("change_24h") or 0) >= float(static_cfg.get("min_top_gainer_change_pct", 8.0)):
signals.append("top_gainer")
if (cand.get("higher_lows") or {}).get("found"):
signals.append("higher_lows")
if (cand.get("compression_surge") or {}).get("found"):
signals.append("compression_surge")
if cand.get("sentiment") or cand.get("sentiment_bonus"):
signals.append("sentiment")
return list(dict.fromkeys(signals))
def _attach_top_gainer_discovery(candidates, tickers, recently_screened):
"""为强势榜补发现入口;追高风险留给细筛/确认处理。"""
added = 0
for symbol, info in tickers.items():
if symbol in candidates:
if _is_top_gainer_candidate(symbol, info):
candidates[symbol]["top_gainer_24h"] = True
candidates[symbol]["top_gainer_chase_risk"] = symbol not in recently_screened
continue
if not _is_top_gainer_candidate(symbol, info):
continue
change = float(info.get("change_24h") or 0)
vol = float(info.get("volume_24h") or 0)
signal = _top_gainer_signal(symbol, change, vol)
if symbol not in recently_screened:
signal = f"{signal},追高风险待确认"
candidates[symbol] = {
"anomalies": [signal],
"anomaly_score": 2,
"price": info["price"],
"change_24h": change,
"volume_24h": vol,
"funding_rate": 0,
"is_meme": is_meme_coin(symbol),
"vp_data": None,
"bb_data": None,
"static_accumulation": None,
"h4_df": None,
"turnover_acceleration_1h": 0,
"turnover_acceleration_4h": 0,
"base_volume_24h": 0,
"quote_volume_24h": round(vol, 2),
"weighted_avg_price": info.get("price", 0),
"top_gainer_24h": True,
"top_gainer_chase_risk": symbol not in recently_screened,
"bypass_origin": "top_gainer_24h",
}
added += 1
return added
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)
is_unseen_top_gainer = change > burst_threshold * 1.5 and symbol not in recently_screened
if anomalies:
if is_unseen_top_gainer:
anomalies.append(_top_gainer_signal(symbol, change, vol) + ",追高风险待确认")
# === 冲高回落检查:量价齐飞后持续阴跌→拒绝 ===
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,
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
"top_gainer_chase_risk": is_unseen_top_gainer,
}
# ==== 第二遍扫描低成交量静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)
is_unseen_top_gainer = change > burst_threshold * 1.5 and symbol not in recently_screened
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)"
]
if is_unseen_top_gainer:
anomalies.append(_top_gainer_signal(symbol, change, vol) + ",追高风险待确认")
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,
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
"top_gainer_chase_risk": is_unseen_top_gainer,
}
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']}"]
if is_unseen_top_gainer:
anomalies.append(_top_gainer_signal(symbol, change, vol) + ",追高风险待确认")
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",
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
"top_gainer_chase_risk": is_unseen_top_gainer,
}
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']}"]
if is_unseen_top_gainer:
anomalies.append(_top_gainer_signal(symbol, change, vol) + ",追高风险待确认")
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",
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
"top_gainer_chase_risk": is_unseen_top_gainer,
}
cs_count_total += 1
added = True
top_gainer_count = _attach_top_gainer_discovery(candidates, tickers, recently_screened)
# 第一道漏斗:把明确不可交易/太低成交额的资产写成独立阶段,研发侧可审计,
# 但不让它们进入后续机会链路。
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};强势榜发现{top_gainer_count}个)")
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,
"top_gainer_discovery_count": top_gainer_count,
}
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
static_resonance = _static_bypass_resonance(
cand,
static_cfg=static_cfg,
sector_signal_count=sector_signal_count,
top_trader_ratio=ratio,
vp_data=vp_data,
)
static_resonance_ok = (
not static_cfg.get("require_resonance", False)
or len(static_resonance) >= int(static_cfg.get("min_resonance_signals", 1))
)
# 静K蓄力旁路即使原始状态是过期有静K蓄力+量比达标+共振→至少蓄力
if (
state == "过期"
and static_accumulation
and static_accumulation["vol_ratio"] >= static_bypass_min_vol_ratio
and score >= static_bypass_min_score
and static_resonance_ok
):
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)
and static_resonance_ok
):
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 cand.get("top_gainer_24h"):
signals.append(f"24h强势榜异动({cand.get('change_24h', 0):.1f}%)")
if cand.get("top_gainer_chase_risk"):
signals.append("追高风险:首次进入强势榜,等待二次结构确认")
elif state == "过期" and score >= static_bypass_min_score:
state = "蓄力"
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 "",
"static_bypass_resonance": static_resonance,
"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,
"static_bypass_resonance": static_resonance if static_accumulation else [],
"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 粗筛/细筛主流程")
parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON便于脚本消费")
args = parser.parse_args()
main(compact=args.compact)